chore: bump version to 0.1538

This commit is contained in:
arkon
2026-02-18 12:51:35 +01:00
parent c548c2d6ee
commit 88422cab57
40 changed files with 369 additions and 3547 deletions
+28 -37
View File
@@ -11,7 +11,7 @@
* - Event emission for state changes
*
* Subclasses implement:
* - `screenNamePrefix`: Prefix for mux session names (e.g., 'claudeman-aicheck-')
* - `muxNamePrefix`: Prefix for mux session names (e.g., 'claudeman-aicheck-')
* - `doneMarker`: Completion marker in output file (e.g., '__AICHECK_DONE__')
* - `tempFilePrefix`: Prefix for temp files (e.g., 'claudeman-aicheck')
* - `logPrefix`: Prefix for log messages (e.g., '[AiIdleChecker]')
@@ -45,12 +45,12 @@ function isValidModelName(model: string): boolean {
}
/**
* Validates that a screen name is safe for shell use.
* Screen names should only contain alphanumeric characters, hyphens, and underscores.
* Validates that a mux session name is safe for shell use.
* Names should only contain alphanumeric characters, hyphens, and underscores.
*/
function isValidScreenName(screenName: string): boolean {
if (!screenName || typeof screenName !== 'string') return false;
return /^[a-zA-Z0-9_-]+$/.test(screenName) && screenName.length <= 100;
function isValidMuxName(muxName: string): boolean {
if (!muxName || typeof muxName !== 'string') return false;
return /^[a-zA-Z0-9_-]+$/.test(muxName) && muxName.length <= 100;
}
// ========== Types ==========
@@ -143,7 +143,7 @@ export abstract class AiCheckerBase<
protected disabledReason: string | null = null;
// Active check state
protected checkScreenName: string | null = null;
protected checkMuxName: string | null = null;
protected checkTempFile: string | null = null;
protected checkPromptFile: string | null = null;
protected checkPollTimer: NodeJS.Timeout | null = null;
@@ -155,7 +155,7 @@ export abstract class AiCheckerBase<
// ========== Abstract Properties ==========
/** Prefix for mux session names (e.g., 'claudeman-aicheck-') */
protected abstract readonly screenNamePrefix: string;
protected abstract readonly muxNamePrefix: string;
/** Marker written to temp file when check is complete */
protected abstract readonly doneMarker: string;
@@ -255,7 +255,7 @@ export abstract class AiCheckerBase<
/**
* Run an AI check against the provided terminal buffer.
* Spawns a fresh Claude CLI in a screen, captures output to temp file.
* Spawns a fresh Claude CLI in a tmux session, captures output to temp file.
*
* @param terminalBuffer - Raw terminal output to analyze
* @returns The verdict result
@@ -387,16 +387,16 @@ export abstract class AiCheckerBase<
// Build the prompt
const prompt = this.buildPrompt(trimmed);
// Generate temp files and screen name
// Generate temp files and mux session name
const shortId = this.sessionId.slice(0, 8);
const timestamp = Date.now();
this.checkTempFile = join(tmpdir(), `${this.tempFilePrefix}-${shortId}-${timestamp}.txt`);
this.checkPromptFile = join(tmpdir(), `${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt`);
this.checkScreenName = `${this.screenNamePrefix}${shortId}`;
this.checkMuxName = `${this.muxNamePrefix}${shortId}`;
// Security: Validate screen name before use in shell commands
if (!isValidScreenName(this.checkScreenName)) {
throw new Error(`Invalid screen name generated: ${this.checkScreenName.substring(0, 50)}`);
// Security: Validate mux name before use in shell commands
if (!isValidMuxName(this.checkMuxName)) {
throw new Error(`Invalid mux name generated: ${this.checkMuxName.substring(0, 50)}`);
}
// Ensure output temp file exists (empty) so we can poll it
@@ -413,26 +413,25 @@ export abstract class AiCheckerBase<
const claudeCmd = `cat "${this.checkPromptFile}" | claude -p ${modelArg} --output-format text`;
const fullCmd = `export PATH="${augmentedPath}"; ${claudeCmd} > "${this.checkTempFile}" 2>&1; echo "${this.doneMarker}" >> "${this.checkTempFile}"; rm -f "${this.checkPromptFile}"`;
// Spawn screen
// Spawn tmux session
try {
// Kill any leftover screen with this name first (screen name already validated above)
// Kill any leftover session with this name first (mux name already validated above)
try {
execSync(`screen -X -S "${this.checkScreenName}" quit 2>/dev/null`, { timeout: 3000 });
execSync(`tmux kill-session -t "${this.checkMuxName}" 2>/dev/null`, { timeout: 3000 });
} catch {
// No existing screen, that's fine
// No existing session, that's fine
}
const screenProcess = childSpawn('screen', [
'-dmS', this.checkScreenName,
'-c', '/dev/null',
const muxProcess = childSpawn('tmux', [
'new-session', '-d', '-s', this.checkMuxName,
'bash', '-c', fullCmd
], {
detached: true,
stdio: 'ignore',
});
screenProcess.unref();
muxProcess.unref();
} catch (err) {
throw new Error(`Failed to spawn ${this.checkDescription} screen: ${err instanceof Error ? err.message : String(err)}`);
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`);
}
// Poll the temp file for completion
@@ -505,23 +504,15 @@ export abstract class AiCheckerBase<
this.checkTimeoutTimer = null;
}
// Kill the screen with fallback for stubborn processes
if (this.checkScreenName) {
const screenName = this.checkScreenName;
// Screen name was validated before use, but still quote for defense-in-depth
// Kill the tmux session
if (this.checkMuxName) {
const muxName = this.checkMuxName;
try {
execSync(`screen -X -S "${screenName}" quit 2>/dev/null`, { timeout: 2000 });
execSync(`tmux kill-session -t "${muxName}" 2>/dev/null`, { timeout: 2000 });
} catch {
// First attempt failed - try force kill via pkill as fallback
// Escape regex metacharacters in screen name to prevent pattern injection
const escapedName = screenName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
try {
execSync(`pkill -f "SCREEN.*${escapedName}" 2>/dev/null`, { timeout: 1000 });
} catch {
// Screen may already be dead or no matching process
}
// Session may already be dead
}
this.checkScreenName = null;
this.checkMuxName = null;
}
// Delete temp files
+6 -6
View File
@@ -1,22 +1,22 @@
/**
* @fileoverview AI-Powered Idle Checker for Respawn Controller
*
* Spawns a fresh Claude CLI session in a screen to analyze terminal output
* Spawns a fresh Claude CLI session in a tmux session to analyze terminal output
* and provide a definitive IDLE/WORKING verdict. This replaces the "Worked for Xm Xs"
* pattern as the primary idle detection signal.
*
* ## How It Works
*
* 1. Generate temp file path for output capture
* 2. Spawn screen: `screen -dmS claudeman-aicheck-<short> bash -c 'claude -p ...'`
* 2. Spawn tmux: `tmux new-session -d -s claudeman-aicheck-<short> bash -c 'claude -p ...'`
* 3. Poll the temp file every 500ms for `__AICHECK_DONE__` marker
* 4. Parse the file content for IDLE/WORKING on the first word
* 5. Kill screen and delete temp file
* 5. Kill tmux session and delete temp file
*
* ## Error Handling
*
* - Screen spawn fails: 1-min cooldown, increment error counter
* - Check times out (90s): Kill screen, 1-min cooldown
* - Tmux spawn fails: 1-min cooldown, increment error counter
* - Check times out (90s): Kill session, 1-min cooldown
* - Can't parse IDLE/WORKING: Treat as WORKING, 1-min cooldown
* - 3 consecutive errors: Disable AI check, fall back to noOutputTimeoutMs
* - Claude CLI not found: Disable permanently
@@ -143,7 +143,7 @@ export class AiIdleChecker extends AiCheckerBase<
AiCheckResult,
AiCheckState
> {
protected readonly screenNamePrefix = 'claudeman-aicheck-';
protected readonly muxNamePrefix = 'claudeman-aicheck-';
protected readonly doneMarker = '__AICHECK_DONE__';
protected readonly tempFilePrefix = 'claudeman-aicheck';
protected readonly logPrefix = '[AiIdleChecker]';
+7 -7
View File
@@ -1,22 +1,22 @@
/**
* @fileoverview AI-Powered Plan Mode Checker for Auto-Accept
*
* Spawns a fresh Claude CLI session to analyze terminal output and determine
* if Claude Code is showing a plan mode approval prompt (numbered selection menu).
* Spawns a fresh Claude CLI session in a tmux session to analyze terminal output
* and determine if Claude Code is showing a plan mode approval prompt.
* Used as a confirmation gate before auto-accepting prompts.
*
* ## How It Works
*
* 1. Generate temp file path for output capture
* 2. Spawn screen: `screen -dmS claudeman-plancheck-<short> bash -c 'claude -p ...'`
* 2. Spawn tmux: `tmux new-session -d -s claudeman-plancheck-<short> bash -c 'claude -p ...'`
* 3. Poll the temp file every 500ms for `__PLANCHECK_DONE__` marker
* 4. Parse the file content for PLAN_MODE/NOT_PLAN_MODE on the first line
* 5. Kill screen and delete temp file
* 5. Kill tmux session and delete temp file
*
* ## Error Handling
*
* - Screen spawn fails: 30s cooldown, increment error counter
* - Check times out (60s): Kill screen, 30s cooldown
* - Tmux spawn fails: 30s cooldown, increment error counter
* - Check times out (60s): Kill session, 30s cooldown
* - Can't parse verdict: Treat as NOT_PLAN_MODE, 30s cooldown
* - 3 consecutive errors: Disable AI plan check
*
@@ -105,7 +105,7 @@ export class AiPlanChecker extends AiCheckerBase<
AiPlanCheckResult,
AiPlanCheckState
> {
protected readonly screenNamePrefix = 'claudeman-plancheck-';
protected readonly muxNamePrefix = 'claudeman-plancheck-';
protected readonly doneMarker = '__PLANCHECK_DONE__';
protected readonly tempFilePrefix = 'claudeman-plancheck';
protected readonly logPrefix = '[AiPlanChecker]';
+8 -45
View File
@@ -1,59 +1,22 @@
/**
* @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.
* @fileoverview Factory for creating the terminal multiplexer (tmux).
*
* @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.
* Create a TerminalMultiplexer instance.
*
* 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
* Requires tmux to be installed. Throws with install instructions if not found.
*/
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();
export function createMultiplexer(): TerminalMultiplexer {
if (!TmuxManager.isTmuxAvailable()) {
throw new Error('tmux not found. Install: sudo apt install tmux');
}
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'
);
console.log('[MuxFactory] Using tmux backend');
return new TmuxManager();
}
+7 -18
View File
@@ -1,11 +1,7 @@
/**
* @fileoverview Terminal multiplexer abstraction layer.
* @fileoverview Terminal multiplexer abstraction layer (tmux).
*
* 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.
* Defines the TerminalMultiplexer interface that TmuxManager implements.
*
* @module mux-interface
*/
@@ -14,8 +10,7 @@ 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`.
* Multiplexer session metadata.
*/
export interface MuxSession {
/** Claudeman session ID */
@@ -51,8 +46,7 @@ export interface MuxSessionWithStats extends MuxSession {
/**
* Terminal multiplexer interface.
*
* Both ScreenManager and TmuxManager implement this interface,
* allowing the rest of the codebase to work with either backend.
* Implemented by TmuxManager.
*
* Events emitted:
* - `sessionCreated` (session: MuxSession) - New session created
@@ -62,7 +56,7 @@ export interface MuxSessionWithStats extends MuxSession {
*/
export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
readonly backend: 'tmux' | 'screen';
readonly backend: 'tmux';
// ========== Lifecycle ==========
@@ -104,9 +98,7 @@ export interface TerminalMultiplexer extends EventEmitter {
// ========== 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)
* Send input to a session via tmux send-keys.
*/
sendInput(sessionId: string, input: string): Promise<boolean>;
@@ -149,15 +141,12 @@ export interface TerminalMultiplexer extends EventEmitter {
// ========== PTY Attachment ==========
/**
* Get the command to spawn for attaching to a session.
* Returns 'tmux' or 'screen'.
* Get the command to spawn for attaching to a session ('tmux').
*/
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[];
+4 -4
View File
@@ -390,8 +390,8 @@ export class PlanOrchestrator {
const session = new Session({
workingDir: this.workingDir,
screenManager: this.mux,
useScreen: false,
mux: this.mux,
useMux: false,
mode: 'claude',
});
@@ -472,8 +472,8 @@ export class PlanOrchestrator {
const session = new Session({
workingDir: this.workingDir,
screenManager: this.mux,
useScreen: false,
mux: this.mux,
useMux: false,
mode: 'claude',
});
+5 -5
View File
@@ -1697,7 +1697,7 @@ export class RespawnController extends EventEmitter {
if (this._state === 'stopped') return;
const prompt = this.config.kickstartPrompt!;
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
await this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
this.emit('stepSent', 'kickstart', prompt);
this.setState('waiting_kickstart');
this.promptDetected = false;
@@ -2466,7 +2466,7 @@ export class RespawnController extends EventEmitter {
this.logAction('command', 'Auto-accept: ↵ Enter (plan approved)');
this.emit('stepSent', 'auto-accept', '↵');
void this.session.writeViaScreen('\r');
void this.session.writeViaMux('\r');
this.emit('autoAcceptSent');
// Reset so we don't keep spamming Enter if Claude doesn't respond
this.hasReceivedOutput = false;
@@ -2886,7 +2886,7 @@ export class RespawnController extends EventEmitter {
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
await this.session.writeViaScreen(input);
await this.session.writeViaMux(input);
this.emit('stepSent', 'update', updatePrompt);
this.setState('waiting_update');
this.promptDetected = false;
@@ -2914,7 +2914,7 @@ export class RespawnController extends EventEmitter {
this.stepTimer = null;
if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /clear');
await this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'clear', '/clear');
this.setState('waiting_clear');
this.promptDetected = false;
@@ -2958,7 +2958,7 @@ export class RespawnController extends EventEmitter {
this.stepTimer = null;
if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /init');
await this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'init', '/init');
this.setState('waiting_init');
this.promptDetected = false;
File diff suppressed because it is too large Load Diff
+39 -50
View File
@@ -8,8 +8,8 @@
* 2. **Interactive mode** (`startInteractive`): Start an interactive Claude session
* 3. **Shell mode**: Run a plain bash shell for debugging/testing
*
* The session can optionally run inside a tmux session (or GNU Screen as fallback)
* for persistence across disconnects. It tracks tokens, costs, background tasks, and supports
* The session can optionally run inside a tmux session for persistence across disconnects.
* It tracks tokens, costs, background tasks, and supports
* auto-clear/auto-compact functionality when token limits are approached.
*
* @module session
@@ -50,8 +50,8 @@ const LINE_BUFFER_FLUSH_INTERVAL = 100;
// Timing Constants
// ============================================================================
/** Delay after screen creation before sending commands (300ms) */
const SCREEN_STARTUP_DELAY_MS = 300;
/** Delay after mux session creation before sending commands (300ms) */
const MUX_STARTUP_DELAY_MS = 300;
/** Delay before declaring session idle after last output (2 seconds) */
const IDLE_DETECTION_DELAY_MS = 2000;
@@ -161,7 +161,7 @@ export interface SessionEvents {
completion: (result: string, cost: number) => void;
/** Raw terminal data (includes ANSI codes) */
terminal: (data: string) => void;
/** Signal to clear terminal display (after screen attach) */
/** Signal to clear terminal display (after mux attach) */
clearTerminal: () => void;
/** New background task started */
taskCreated: (task: BackgroundTask) => void;
@@ -210,8 +210,8 @@ export type SessionMode = 'claude' | 'shell';
* // Create and start an interactive Claude session
* const session = new Session({
* workingDir: '/path/to/project',
* screenManager: screenManager,
* useScreen: true
* mux: muxManager,
* useMux: true
* });
* await session.startInteractive();
*
@@ -300,7 +300,7 @@ export class Session extends EventEmitter {
private _promptCheckTimeout: NodeJS.Timeout | null = null;
private _shellIdleTimer: NodeJS.Timeout | null = null;
// Multiplexer session support (tmux or GNU Screen)
// Multiplexer session support (tmux)
private _mux: TerminalMultiplexer | null = null;
private _muxSession: MuxSession | null = null;
private _useMux: boolean = false;
@@ -354,18 +354,12 @@ export class Session extends EventEmitter {
workingDir: string;
mode?: SessionMode;
name?: string;
/** Terminal multiplexer instance (tmux or screen) */
/** Terminal multiplexer instance (tmux) */
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;
/** @deprecated Use `muxSession` instead */
screenSession?: MuxSession;
niceConfig?: NiceConfig; // Nice prioritying configuration
}) {
super();
@@ -388,10 +382,9 @@ export class Session extends EventEmitter {
// This ensures subagent matching works even for recovered sessions (where
// startInteractive() hasn't been called yet).
this._claudeSessionId = this.id;
// 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;
this._mux = config.mux || null;
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
this._muxSession = config.muxSession || null;
// Apply Nice priority configuration if provided
if (config.niceConfig) {
@@ -815,14 +808,14 @@ export class Session extends EventEmitter {
* Starts an interactive Claude CLI session with full terminal support.
*
* This spawns Claude CLI with `--dangerously-skip-permissions` flag in
* interactive mode. If screen wrapping is enabled, the session runs inside
* a tmux session (or GNU Screen as fallback) for persistence across disconnects.
* interactive mode. If mux wrapping is enabled, the session runs inside
* a tmux session for persistence across disconnects.
*
* @throws {Error} If a process is already running in this session
*
* @example
* ```typescript
* const session = new Session({ workingDir: '/project', useScreen: true });
* const session = new Session({ workingDir: '/project', useMux: true });
* await session.startInteractive();
* session.on('terminal', (data) => process.stdout.write(data));
* session.write('help me with this code\r');
@@ -856,7 +849,7 @@ export class Session extends EventEmitter {
console.log('[Session] Created mux session:', this._muxSession.muxName);
// Wait a moment for mux to fully start
await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS));
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
// Attach to the mux session via PTY
@@ -876,13 +869,13 @@ export class Session extends EventEmitter {
// 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);
this.emit('error', `Failed to attach to screen: ${spawnErr}`);
console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
throw spawnErr;
}
// For NEW screens: wait for prompt to appear then clean buffer
// For RESTORED screens: don't do anything - client will fetch buffer on tab switch
// For NEW mux sessions: wait for prompt to appear then clean buffer
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
if (!isRestoredSession) {
this._promptCheckInterval = setInterval(() => {
// Wait for the prompt character (❯) which means Claude is fully initialized
@@ -896,7 +889,7 @@ export class Session extends EventEmitter {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// Clean the buffer - remove screen init junk before actual content
// Clean the buffer - remove mux init junk before actual content
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
this._terminalBuffer.set(
@@ -942,7 +935,7 @@ export class Session extends EventEmitter {
COLORTERM: undefined,
CLAUDECODE: undefined,
// Inform Claude it's running within Claudeman (helps prevent self-termination)
CLAUDEMAN_SCREEN: '1',
CLAUDEMAN_MUX: '1',
CLAUDEMAN_SESSION_ID: this.id,
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
},
@@ -1136,7 +1129,7 @@ export class Session extends EventEmitter {
console.log('[Session] Created mux session:', this._muxSession.muxName);
// Wait a moment for mux to fully start
await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS));
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
// Attach to the mux session via PTY
@@ -1185,7 +1178,7 @@ export class Session extends EventEmitter {
env: {
...process.env,
TERM: 'xterm-256color',
CLAUDEMAN_SCREEN: '1',
CLAUDEMAN_MUX: '1',
CLAUDEMAN_SESSION_ID: this.id,
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
},
@@ -1317,7 +1310,7 @@ export class Session extends EventEmitter {
COLORTERM: undefined,
CLAUDECODE: undefined,
// Inform Claude it's running within Claudeman
CLAUDEMAN_SCREEN: '1',
CLAUDEMAN_MUX: '1',
CLAUDEMAN_SESSION_ID: this.id,
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
},
@@ -1770,7 +1763,7 @@ export class Session extends EventEmitter {
const compactCmd = this._autoCompactPrompt
? `/compact ${this._autoCompactPrompt}\r`
: '/compact\r';
await this.writeViaScreen(compactCmd);
await this.writeViaMux(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
@@ -1821,7 +1814,7 @@ export class Session extends EventEmitter {
if (this._isStopped) return;
// Send /clear command
await this.writeViaScreen('/clear\r');
await this.writeViaMux('/clear\r');
// Reset token counts
this._totalInputTokens = 0;
this._totalOutputTokens = 0;
@@ -1876,22 +1869,18 @@ export class Session extends EventEmitter {
*
* More reliable than direct PTY write for programmatic input, especially
* 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.
* Uses tmux `send-keys -l` to inject text + Enter.
*
* @param data - Input data with optional `\r` for Enter
* @returns true if input was sent, false if no mux session or PTY
*
* @example
* ```typescript
* session.writeViaScreen('/clear\r'); // Send /clear command
* session.writeViaScreen('/init\r'); // Send /init command
* session.writeViaMux('/clear\r'); // Send /clear command
* session.writeViaMux('/init\r'); // Send /init command
* ```
*/
async writeViaScreen(data: string): Promise<boolean> {
async writeViaMux(data: string): Promise<boolean> {
if (this._mux && this._muxSession) {
return this._mux.sendInput(this.id, data);
}
@@ -1974,21 +1963,21 @@ export class Session extends EventEmitter {
/**
* Stops the session and cleans up resources.
*
* This kills the PTY process and optionally the associated tmux/screen
* session. All buffers are cleared and the session is marked as stopped.
* This kills the PTY process and optionally the associated tmux session.
* All buffers are cleared and the session is marked as stopped.
*
* @param killScreen - Whether to also kill the screen session (default: true)
* @param killMux - Whether to also kill the mux session (default: true)
*
* @example
* ```typescript
* // Stop and kill everything
* await session.stop();
*
* // Stop but keep screen running for later reattachment
* // Stop but keep mux session running for later reattachment
* await session.stop(false);
* ```
*/
async stop(killScreen: boolean = true): Promise<void> {
async stop(killMux: boolean = true): Promise<void> {
// Set stopped flag first to prevent new timers from being created
this._isStopped = true;
@@ -2034,7 +2023,7 @@ export class Session extends EventEmitter {
}
// Immediately cleanup Promise callbacks to prevent orphaned references
// during the rest of stop() processing (e.g., if screen kill times out)
// during the rest of stop() processing (e.g., if mux kill times out)
if (this.rejectPromise && !this._promptResolved) {
this._promptResolved = true;
this.rejectPromise(new Error('Session stopped'));
@@ -2087,7 +2076,7 @@ export class Session extends EventEmitter {
this._childAgentIds = [];
// Kill the associated mux session if requested
if (killScreen && this._mux) {
if (killMux && this._mux) {
// Try to kill mux session even if _muxSession is not set (e.g., restored sessions)
try {
const killed = await this._mux.killSession(this.id);
@@ -2098,7 +2087,7 @@ export class Session extends EventEmitter {
console.error('[Session] Failed to kill mux session:', err);
}
this._muxSession = null;
} else if (this._muxSession && !killScreen) {
} else if (this._muxSession && !killMux) {
console.log('[Session] Keeping mux session alive:', this._muxSession.muxName);
this._muxSession = null; // Detach but don't kill
}
+3 -3
View File
@@ -20,10 +20,10 @@ Then update the Project Overview section below.
## Claudeman Environment
This session is managed by **Claudeman** and runs within a tmux session (or GNU Screen as fallback).
This session is managed by **Claudeman** and runs within a tmux session.
**Important**: Check for `CLAUDEMAN_SCREEN=1` environment variable to confirm.
- Do NOT attempt to kill your own tmux/screen session
**Important**: Check for `CLAUDEMAN_MUX=1` environment variable to confirm.
- Do NOT attempt to kill your own tmux session
- The session persists across disconnects - your work is safe
- Token usage, costs, and background tasks are tracked externally
+13 -41
View File
@@ -12,11 +12,11 @@
*
* 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)
* Key features:
* - `send-keys 'text' Enter` sends literal text in a single command
* - `list-sessions -F` provides structured queries
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
* - Single server architecture vs per-session processes
* - Single server architecture
*
* @module tmux-manager
*/
@@ -44,7 +44,7 @@ import { findClaudeDir } from './utils/claude-cli-resolver.js';
/** Timeout for exec commands (5 seconds) */
const EXEC_TIMEOUT_MS = 5000;
/** Delay after tmux session creation (300ms — faster than screen's 500ms) */
/** Delay after tmux session creation (300ms) */
const TMUX_CREATION_WAIT_MS = 300;
/** Delay after tmux kill command (200ms) */
@@ -74,9 +74,6 @@ const IS_TEST_MODE = !!process.env.VITEST;
/** 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-]+$/;
@@ -115,8 +112,7 @@ function isValidPath(path: string): boolean {
/**
* Manages tmux sessions that wrap Claude CLI or shell processes.
*
* Implements the TerminalMultiplexer interface for use as a drop-in
* replacement for ScreenManager.
* Implements the TerminalMultiplexer interface.
*
* @example
* ```typescript
@@ -163,30 +159,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
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);
@@ -262,9 +234,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const envExports = [
'unset CLAUDECODE',
'unset COLORTERM',
'export CLAUDEMAN_SCREEN=1',
'export CLAUDEMAN_MUX=1',
`export CLAUDEMAN_SESSION_ID=${sessionId}`,
`export CLAUDEMAN_SCREEN_NAME=${muxName}`,
`export CLAUDEMAN_MUX_NAME=${muxName}`,
`export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`,
].join(' && ');
@@ -440,7 +412,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/**
* Kill a tmux session and all its child processes.
* Uses the same 4-strategy approach as ScreenManager.
* Uses a 4-strategy approach (children → process group → tmux kill → SIGKILL).
* In test mode: removes from memory only (no real kill).
*/
async killSession(sessionId: string): Promise<boolean> {
@@ -457,7 +429,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// SAFETY: Never kill the tmux session we're running inside of
const currentMuxName = process.env.CLAUDEMAN_SCREEN_NAME;
const currentMuxName = process.env.CLAUDEMAN_MUX_NAME;
if (currentMuxName && session.muxName === currentMuxName) {
console.error(`[TmuxManager] BLOCKED: Refusing to kill own tmux session: ${session.muxName}`);
return false;
@@ -899,10 +871,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/**
* Send input directly to a tmux session using `send-keys`.
*
* This is significantly simpler than Screen's approach:
* Uses tmux send-keys for reliable input delivery:
* - `-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
* - `Enter` key is sent as a separate argument
* - Single command, no delay needed
*/
async sendInput(sessionId: string, input: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
+3 -39
View File
@@ -7,7 +7,7 @@
* - Task queue operations
* - Ralph Loop configuration
* - API requests/responses
* - Screen session handling
* - Mux session handling
* - Inner loop tracking (Ralph Wiggum detection)
*/
@@ -852,15 +852,10 @@ export interface CaseInfo {
hasClaudeMd?: boolean;
}
// ========== Screen Session Types ==========
// ========== Mux Session Types ==========
/**
* Terminal multiplexer session wrapper.
*
* Claudeman uses tmux (preferred) or GNU Screen (fallback) for session persistence.
*/
/**
* Persisted respawn configuration for screen sessions.
* Persisted respawn configuration for mux sessions.
* Subset of RespawnConfig that gets saved to disk.
*/
export interface PersistedRespawnConfig {
@@ -910,29 +905,6 @@ export interface PersistedRespawnConfig {
durationMinutes?: number;
}
export interface ScreenSession {
/** Claudeman session ID */
sessionId: string;
/** Mux session name (claudeman-<id>) */
screenName: string;
/** Screen 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 screen */
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;
}
/**
* Process resource statistics
*/
@@ -947,14 +919,6 @@ export interface ProcessStats {
updatedAt: number;
}
/**
* Screen session with resource statistics
*/
export interface ScreenSessionWithStats extends ScreenSession {
/** Optional resource statistics */
stats?: ProcessStats;
}
// ========== Default Configuration ==========
/**
+2 -2
View File
@@ -2,8 +2,8 @@
* @fileoverview Shared Claude CLI binary resolution.
*
* Finds the `claude` binary across common installation paths and provides
* an augmented PATH string. Used by session.ts, tmux-manager.ts, and
* screen-manager.ts to locate the Claude CLI.
* an augmented PATH string. Used by session.ts and tmux-manager.ts
* to locate the Claude CLI.
*
* @module utils/claude-cli-resolver
*/
+1 -2
View File
@@ -1,8 +1,7 @@
/**
* @fileoverview Shared utility for wrapping commands with `nice` priority.
*
* Extracted from screen-manager.ts so both tmux-manager and screen-manager
* can use it without cross-dependency on the deprecated screen module.
* Extracted as a shared utility for tmux-manager and other consumers.
*
* @module utils/nice-wrapper
*/
+63 -63
View File
@@ -1350,7 +1350,7 @@ class ClaudemanApp {
this.terminalBuffers = new Map(); // Store terminal content per session
this.editingSessionId = null; // Session being edited in options modal
this.pendingCloseSessionId = null; // Session pending close confirmation
this.screenSessions = []; // Screen sessions for process monitor
this.muxSessions = []; // Screen sessions for process monitor
// Ralph loop/todo state per session
this.ralphStates = new Map(); // Map<sessionId, { loop, todos }>
@@ -1428,7 +1428,7 @@ class ClaudemanApp {
this.renderSessionTabsTimeout = null;
this.renderRalphStatePanelTimeout = null;
this.renderTaskPanelTimeout = null;
this.renderScreenSessionsTimeout = null;
this.renderMuxSessionsTimeout = null;
// System stats polling
this.systemStatsInterval = null;
@@ -2766,29 +2766,29 @@ class ClaudemanApp {
});
// Screen events
addListener('screen:created', (e) => {
addListener('mux:created', (e) => {
const screen = JSON.parse(e.data);
this.screenSessions.push(screen);
this.renderScreenSessions();
this.muxSessions.push(screen);
this.renderMuxSessions();
});
addListener('screen:killed', (e) => {
addListener('mux:killed', (e) => {
const data = JSON.parse(e.data);
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
this.renderScreenSessions();
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId);
this.renderMuxSessions();
});
addListener('screen:died', (e) => {
addListener('mux:died', (e) => {
const data = JSON.parse(e.data);
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
this.renderScreenSessions();
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId);
this.renderMuxSessions();
this.showToast('Mux session died: ' + data.sessionId.slice(0, 8), 'warning');
});
addListener('screen:statsUpdated', (e) => {
this.screenSessions = JSON.parse(e.data);
addListener('mux:statsUpdated', (e) => {
this.muxSessions = JSON.parse(e.data);
if (document.getElementById('monitorPanel').classList.contains('open')) {
this.renderScreenSessions();
this.renderMuxSessions();
}
});
@@ -3272,7 +3272,7 @@ class ClaudemanApp {
// Chain on dispatch only — wait for the previous request to be sent before
// dispatching the next one (preserves keystroke ordering), but don't wait
// for the server's response. The server handles writeViaScreen as
// for the server's response. The server handles writeViaMux as
// fire-and-forget anyway, so the HTTP response carries no useful data
// beyond success/failure for retry purposes.
this._inputSendChain = this._inputSendChain.then(() => {
@@ -4318,9 +4318,9 @@ class ClaudemanApp {
delete this.respawnActionLogs[sessionId];
}
async closeSession(sessionId, killScreen = true) {
async closeSession(sessionId, killMux = true) {
try {
await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' });
await fetch(`/api/sessions/${sessionId}?killMux=${killMux}`, { method: 'DELETE' });
this._cleanupSessionData(sessionId);
if (this.activeSessionId === sessionId) {
@@ -4339,7 +4339,7 @@ class ClaudemanApp {
this.renderSessionTabs();
if (killScreen) {
if (killMux) {
this.showToast('Session closed and tmux killed', 'success');
} else {
this.showToast('Tab hidden, tmux still running', 'info');
@@ -4369,12 +4369,12 @@ class ClaudemanApp {
document.getElementById('closeConfirmModal').classList.remove('active');
}
async confirmCloseSession(killScreen = true) {
async confirmCloseSession(killMux = true) {
const sessionId = this.pendingCloseSessionId;
this.cancelCloseSession();
if (sessionId) {
await this.closeSession(sessionId, killScreen);
await this.closeSession(sessionId, killMux);
}
}
@@ -7187,7 +7187,7 @@ class ClaudemanApp {
}
// Step 3: Enable respawn if requested (with Ralph-specific prompts)
// NOTE: Prompts must be single-line because screen-manager.ts strips newlines
// NOTE: Prompts must be single-line because tmux send-keys handles them as single input
if (config.enableRespawn) {
const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
@@ -7269,7 +7269,7 @@ class ClaudemanApp {
const initRes = await fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '/init\r', useScreen: true })
body: JSON.stringify({ input: '/init\r', useMux: true })
});
if (!initRes.ok) {
@@ -7319,7 +7319,7 @@ class ClaudemanApp {
const inputRes = await fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: readCommand, useScreen: true })
body: JSON.stringify({ input: readCommand, useMux: true })
});
if (!inputRes.ok) {
@@ -8363,7 +8363,7 @@ class ClaudemanApp {
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, useScreen: true })
body: JSON.stringify({ input, useMux: true })
});
}
@@ -10473,13 +10473,13 @@ class ClaudemanApp {
if (panel.classList.contains('open')) {
// Load screens and start stats collection
await this.loadScreens();
await fetch('/api/screens/stats/start', { method: 'POST' });
await this.loadMuxSessions();
await fetch('/api/mux-sessions/stats/start', { method: 'POST' });
this.renderTaskPanel();
if (toggleBtn) toggleBtn.innerHTML = '&#x25BC;'; // Down arrow when open
} else {
// Stop stats collection when panel is closed
await fetch('/api/screens/stats/stop', { method: 'POST' });
await fetch('/api/mux-sessions/stats/stop', { method: 'POST' });
if (toggleBtn) toggleBtn.innerHTML = '&#x25B2;'; // Up arrow when closed
}
}
@@ -14659,19 +14659,19 @@ class ClaudemanApp {
// ========== Mux Sessions (in Monitor Panel) ==========
async loadScreens() {
async loadMuxSessions() {
try {
const res = await fetch('/api/screens');
const res = await fetch('/api/mux-sessions');
const data = await res.json();
this.screenSessions = data.screens || [];
this.renderScreenSessions();
this.muxSessions = data.sessions || [];
this.renderMuxSessions();
} catch (err) {
console.error('Failed to load screens:', err);
console.error('Failed to load mux sessions:', err);
}
}
killAllSessions() {
const count = this.screenSessions?.length || 0;
const count = this.muxSessions?.length || 0;
if (count === 0) {
alert('No sessions to kill');
return;
@@ -14697,27 +14697,27 @@ class ClaudemanApp {
}
}
async confirmKillAll(killScreens) {
async confirmKillAll(killMux) {
this.closeKillAllModal();
try {
if (killScreens) {
if (killMux) {
// Kill everything including tmux sessions
const res = await fetch('/api/sessions', { method: 'DELETE' });
const data = await res.json();
if (data.success) {
this.sessions.clear();
this.screenSessions = [];
this.muxSessions = [];
this.activeSessionId = null;
try { localStorage.removeItem('claudeman-active-session'); } catch {}
this.renderSessionTabs();
this.renderScreenSessions();
this.renderMuxSessions();
this.terminal.clear();
this.terminal.reset();
this.toast('All sessions and tmux killed', 'success');
}
} else {
// Just remove tabs, keep screens running
// Just remove tabs, keep mux sessions running
this.sessions.clear();
this.activeSessionId = null;
try { localStorage.removeItem('claudeman-active-session'); } catch {}
@@ -15048,43 +15048,43 @@ class ClaudemanApp {
setTimeout(() => modal.classList.remove('from-mobile'), 300);
}
renderScreenSessions() {
renderMuxSessions() {
// Debounce renders at 100ms to prevent excessive DOM updates
if (this.renderScreenSessionsTimeout) {
clearTimeout(this.renderScreenSessionsTimeout);
if (this.renderMuxSessionsTimeout) {
clearTimeout(this.renderMuxSessionsTimeout);
}
this.renderScreenSessionsTimeout = setTimeout(() => {
this._renderScreenSessionsImmediate();
this.renderMuxSessionsTimeout = setTimeout(() => {
this._renderMuxSessionsImmediate();
}, 100);
}
_renderScreenSessionsImmediate() {
const body = document.getElementById('screenSessionsBody');
_renderMuxSessionsImmediate() {
const body = document.getElementById('muxSessionsBody');
if (!this.screenSessions || this.screenSessions.length === 0) {
if (!this.muxSessions || this.muxSessions.length === 0) {
body.innerHTML = '<div class="monitor-empty">No mux sessions</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' : '';
for (const muxSession of this.muxSessions) {
const stats = muxSession.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 };
const modeClass = muxSession.mode === 'shell' ? 'shell' : '';
html += `
<div class="process-item">
<span class="process-mode ${modeClass}">${screen.mode}</span>
<span class="process-mode ${modeClass}">${muxSession.mode}</span>
<div class="process-info">
<div class="process-name">${this.escapeHtml(screen.name || screen.screenName)}</div>
<div class="process-name">${this.escapeHtml(muxSession.name || muxSession.muxName)}</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>
<span>PID: ${muxSession.pid}</span>
</div>
</div>
<div class="process-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killScreen('${this.escapeHtml(screen.sessionId)}')" title="Kill tmux session">Kill</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killMuxSession('${this.escapeHtml(muxSession.sessionId)}')" title="Kill tmux session">Kill</button>
</div>
</div>
`;
@@ -15136,32 +15136,32 @@ class ClaudemanApp {
body.innerHTML = html;
}
async killScreen(sessionId) {
async killMuxSession(sessionId) {
if (!confirm('Kill this mux session?')) return;
try {
await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' });
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== sessionId);
this.renderScreenSessions();
await fetch(`/api/mux-sessions/${sessionId}`, { method: 'DELETE' });
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== sessionId);
this.renderMuxSessions();
this.showToast('Tmux session killed', 'success');
} catch (err) {
this.showToast('Failed to kill tmux session', 'error');
}
}
async reconcileScreens() {
async reconcileMuxSessions() {
try {
const res = await fetch('/api/screens/reconcile', { method: 'POST' });
const res = await fetch('/api/mux-sessions/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();
this.showToast(`Found ${data.dead.length} dead mux session(s)`, 'warning');
await this.loadMuxSessions();
} else {
this.showToast('All screens are alive', 'success');
this.showToast('All mux sessions are alive', 'success');
}
} catch (err) {
this.showToast('Failed to reconcile screens', 'error');
this.showToast('Failed to reconcile mux sessions', 'error');
}
}
+5 -5
View File
@@ -385,13 +385,13 @@
</div>
</div>
<!-- Monitor Panel (Screen Sessions + Background Tasks) -->
<!-- Monitor Panel (Tmux Sessions + Background Tasks) -->
<div class="monitor-panel" id="monitorPanel">
<div class="monitor-panel-header" id="monitorPanelHeader">
<div class="monitor-panel-title">Monitor</div>
<div class="monitor-panel-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killAllSessions()" title="Kill all sessions and their tmux processes">Kill All</button>
<button class="btn-icon-sm" onclick="app.reconcileScreens()" title="Refresh screens">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.reconcileMuxSessions()" title="Refresh tmux sessions">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.toggleMonitorDetach()" title="Detach panel" id="monitorDetachBtn">&#x29C9;</button>
<button class="btn-icon-sm" onclick="app.toggleMonitorPanel()" title="Toggle panel" id="monitorToggleBtn">&#x25B2;</button>
<button class="btn-icon-sm" onclick="app.closeMonitor()" title="Close monitor">&times;</button>
@@ -401,10 +401,10 @@
<div class="monitor-panel-body" id="monitorTabContent">
<div class="monitor-section">
<div class="monitor-section-header">
<span>Screen Sessions</span>
<span>Tmux Sessions</span>
</div>
<div class="monitor-section-body" id="screenSessionsBody">
<div class="monitor-empty">No screen sessions</div>
<div class="monitor-section-body" id="muxSessionsBody">
<div class="monitor-empty">No mux sessions</div>
</div>
</div>
<div class="monitor-section" id="monitorPlanAgentsSection" style="display: none;">
+1 -1
View File
@@ -4626,7 +4626,7 @@ kbd {
color: var(--yellow);
}
/* Combined Monitor Panel (Screen Sessions + Background Tasks) */
/* Combined Monitor Panel (Tmux Sessions + Background Tasks) */
.monitor-panel {
position: fixed;
bottom: var(--toolbar-height);
+3 -3
View File
@@ -41,7 +41,7 @@ const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_'];
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
'CLAUDEMAN_SCREEN_NAME', 'CLAUDEMAN_TMUX',
'CLAUDEMAN_MUX_NAME', 'CLAUDEMAN_TMUX',
]);
/** Validate that an env var key is allowed */
@@ -86,7 +86,7 @@ export const RunPromptSchema = z.object({
*/
export const SessionInputSchema = z.object({
input: z.string(),
useScreen: z.boolean().optional(),
useMux: z.boolean().optional(),
});
/**
@@ -193,7 +193,7 @@ export const SettingsUpdateSchema = z.object({
*/
export const SessionInputWithLimitSchema = z.object({
input: z.string().max(100000), // 100KB max input
useScreen: z.boolean().optional(),
useMux: z.boolean().optional(),
});
// ========== Session Mutation Routes ==========
+75 -75
View File
@@ -429,18 +429,18 @@ export class WebServer extends EventEmitter {
}
this.mux = createMultiplexer();
// Set up mux event listeners (SSE event names kept as screen:* for frontend compat)
// Set up mux event listeners
this.mux.on('sessionCreated', (session) => {
this.broadcast('screen:created', session);
this.broadcast('mux:created', session);
});
this.mux.on('sessionKilled', (data) => {
this.broadcast('screen:killed', data);
this.broadcast('mux:killed', data);
});
this.mux.on('sessionDied', (data) => {
this.broadcast('screen:died', data);
this.broadcast('mux:died', data);
});
this.mux.on('statsUpdated', (screens) => {
this.broadcast('screen:statsUpdated', screens);
this.mux.on('statsUpdated', (sessions) => {
this.broadcast('mux:statsUpdated', sessions);
});
// Set up subagent watcher listeners
@@ -789,8 +789,8 @@ export class WebServer extends EventEmitter {
workingDir,
mode: body.mode || 'claude',
name: body.name || '',
screenManager: this.mux,
useScreen: true,
mux: this.mux,
useMux: true,
niceConfig: globalNice,
});
@@ -820,7 +820,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
// Also update the mux session name if applicable
this.mux.updateSessionName(id, session.name);
this.persistSessionState(session);
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
@@ -854,14 +854,14 @@ export class WebServer extends EventEmitter {
this.app.delete('/api/sessions/:id', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const query = req.query as { killScreen?: string };
const killScreen = query.killScreen !== 'false'; // Default to true
const query = req.query as { killMux?: string };
const killMux = query.killMux !== 'false'; // Default to true
if (!this.sessions.has(id)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
await this.cleanupSession(id, killScreen);
await this.cleanupSession(id, killMux);
return { success: true };
});
@@ -1512,7 +1512,7 @@ export class WebServer extends EventEmitter {
});
// Write Ralph prompt to file in session's working directory
// This avoids screen input escaping issues with long multi-line prompts
// This avoids mux input escaping issues with long multi-line prompts
this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
const { id } = req.params as { id: string };
const promptResult = RalphPromptWriteSchema.safeParse(req.body);
@@ -1630,14 +1630,14 @@ export class WebServer extends EventEmitter {
});
// Send input to interactive session
// useScreen: true uses writeViaScreen which is more reliable for programmatic input
// useMux: true uses writeViaMux which is more reliable for programmatic input
this.app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const result = SessionInputWithLimitSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { input, useScreen } = result.data;
const { input, useMux } = result.data;
const session = this.sessions.get(id);
if (!session) {
@@ -1649,14 +1649,14 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
}
// Write input to PTY. Direct write is synchronous; writeViaScreen
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
if (useScreen) {
if (useMux) {
// Fire-and-forget: don't block HTTP response on tmux child process.
// Fallback to direct write on failure.
session.writeViaScreen(inputStr).then(ok => {
session.writeViaMux(inputStr).then(ok => {
if (!ok) {
console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`);
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
session.write(inputStr);
}
}).catch(() => {
@@ -1766,7 +1766,7 @@ export class WebServer extends EventEmitter {
return { success: true, config: controller.getConfig(), active: true };
}
// Return pre-saved config from screens.json
// Return pre-saved config from mux-sessions.json
const preConfig = this.mux.getSession(id)?.respawnConfig;
if (preConfig) {
return { success: true, config: preConfig, active: false };
@@ -1795,7 +1795,7 @@ export class WebServer extends EventEmitter {
// Create or get existing controller
let controller = this.respawnControllers.get(id);
if (!controller) {
// Merge request body with pre-saved config from screens.json
// Merge request body with pre-saved config from mux-sessions.json
const preConfig = this.mux.getSession(id)?.respawnConfig;
const config = body || preConfig ? { ...preConfig, ...body } : undefined;
controller = new RespawnController(session, config);
@@ -2566,8 +2566,8 @@ export class WebServer extends EventEmitter {
const niceConfig = await this.getGlobalNiceConfig();
const session = new Session({
workingDir: casePath,
screenManager: this.mux,
useScreen: true,
mux: this.mux,
useMux: true,
mode: mode,
niceConfig: niceConfig,
});
@@ -2738,8 +2738,8 @@ 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.mux,
useScreen: false, // No screen needed for one-shot
mux: this.mux,
useMux: false, // No mux needed for one-shot
mode: 'claude',
});
@@ -3449,38 +3449,38 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
});
// ============ Screen Management Endpoints ============
// ============ Mux Session Management Endpoints ============
// Get all tracked screens with stats
this.app.get('/api/screens', async () => {
const screens = await this.mux.getSessionsWithStats();
// Get all tracked mux sessions with stats
this.app.get('/api/mux-sessions', async () => {
const sessions = await this.mux.getSessionsWithStats();
return {
screens,
screenAvailable: this.mux.isAvailable()
sessions,
muxAvailable: this.mux.isAvailable()
};
});
// Kill a mux session
this.app.delete('/api/screens/:sessionId', async (req) => {
this.app.delete('/api/mux-sessions/:sessionId', async (req) => {
const { sessionId } = req.params as { sessionId: string };
const success = await this.mux.killSession(sessionId);
return { success };
});
// Reconcile screens (find dead ones)
this.app.post('/api/screens/reconcile', async () => {
// Reconcile mux sessions (find dead ones)
this.app.post('/api/mux-sessions/reconcile', async () => {
const result = await this.mux.reconcileSessions();
return result;
});
// Start stats collection
this.app.post('/api/screens/stats/start', async () => {
this.app.post('/api/mux-sessions/stats/start', async () => {
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
return { success: true };
});
// Stop stats collection
this.app.post('/api/screens/stats/stop', async () => {
this.app.post('/api/mux-sessions/stats/stop', async () => {
this.mux.stopStatsCollection();
return { success: true };
});
@@ -3891,19 +3891,19 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Track sessions currently being cleaned up to prevent concurrent cleanup races
private cleaningUp: Set<string> = new Set();
private async cleanupSession(sessionId: string, killScreen: boolean = true): Promise<void> {
private async cleanupSession(sessionId: string, killMux: boolean = true): Promise<void> {
// Guard against concurrent cleanup of the same session
if (this.cleaningUp.has(sessionId)) return;
this.cleaningUp.add(sessionId);
try {
await this._doCleanupSession(sessionId, killScreen);
await this._doCleanupSession(sessionId, killMux);
} finally {
this.cleaningUp.delete(sessionId);
}
}
private async _doCleanupSession(sessionId: string, killScreen: boolean): Promise<void> {
private async _doCleanupSession(sessionId: string, killMux: boolean): Promise<void> {
const session = this.sessions.get(sessionId);
// Stop watching @fix_plan.md for this session
@@ -3912,7 +3912,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
// Kill all subagents spawned by this session
if (session && killScreen) {
if (session && killMux) {
try {
await subagentWatcher.killSubagentsForSession(session.workingDir);
} catch (err) {
@@ -3994,7 +3994,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
if (session) {
// Accumulate tokens to global stats before removing session
// This preserves lifetime usage even after sessions are deleted
if (killScreen && (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0)) {
if (killMux && (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0)) {
this.store.addToGlobalStats(session.inputTokens, session.outputTokens, session.totalCost);
// Record to daily stats (for what hasn't been recorded yet via periodic recording)
const lastRecorded = this.lastRecordedTokens.get(sessionId) || { input: 0, output: 0 };
@@ -4043,11 +4043,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
fileStreamManager.closeSessionStreams(sessionId);
// Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId);
await session.stop(killScreen);
await session.stop(killMux);
this.sessions.delete(sessionId);
// Only remove from state.json if we're also killing the screen.
// When killScreen=false (server shutdown), preserve state for recovery.
if (killScreen) {
// Only remove from state.json if we're also killing the mux session.
// When killMux=false (server shutdown), preserve state for recovery.
if (killMux) {
this.store.removeSession(sessionId);
}
}
@@ -4083,7 +4083,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
},
clearTerminal: () => {
// Tell clients to clear their terminal (after screen attach)
// Tell clients to clear their terminal (after mux attach)
this.broadcast('session:clearTerminal', { id: session.id });
},
@@ -4452,7 +4452,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
*
* @param session - The session to attach the controller to
* @param config - The persisted respawn configuration
* @param source - Source of the config for logging (e.g., 'state.json' or 'screens.json')
* @param source - Source of the config for logging (e.g., 'state.json' or 'mux-sessions.json')
*/
private restoreRespawnController(
session: Session,
@@ -5074,10 +5074,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// This prevents race conditions where clients connect before state is ready
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
if (!this.testMode) {
await this.restoreScreenSessions();
await this.restoreMuxSessions();
}
// Clean up stale sessions from state file that don't have active screens
// Clean up stale sessions from state file that don't have active mux sessions
this.cleanupStaleSessions();
await this.app.listen({ port: this.port, host: '0.0.0.0' });
@@ -5156,9 +5156,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return false; // Default disabled (matches UI default)
}
private async restoreScreenSessions(): Promise<void> {
private async restoreMuxSessions(): Promise<void> {
try {
// Reconcile screens to find which ones are still alive (also discovers unknown screens)
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
const { alive, dead, discovered } = await this.mux.reconcileSessions();
if (discovered.length > 0) {
@@ -5168,31 +5168,31 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
if (alive.length > 0 || discovered.length > 0) {
console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`);
// For each alive screen, create a Session object if it doesn't exist
const screens = this.mux.getSessions();
for (const screen of screens) {
if (!this.sessions.has(screen.sessionId)) {
// For each alive mux session, create a Session object if it doesn't exist
const muxSessions = this.mux.getSessions();
for (const muxSession of muxSessions) {
if (!this.sessions.has(muxSession.sessionId)) {
// Restore session settings from state.json (single source of truth)
const savedState = this.store.getSession(screen.sessionId);
const savedState = this.store.getSession(muxSession.sessionId);
// Determine the correct session name (priority: savedState > screen > muxName)
// Determine the correct session name (priority: savedState > muxSession > muxName)
// This ensures renamed sessions keep their name after server restart
const sessionName = savedState?.name || screen.name || screen.muxName;
const sessionName = savedState?.name || muxSession.name || muxSession.muxName;
// Create a session object for this mux session with the existing session
// Create a session object for this mux session
const session = new Session({
id: screen.sessionId, // Preserve the original session ID
workingDir: screen.workingDir,
mode: screen.mode,
id: muxSession.sessionId, // Preserve the original session ID
workingDir: muxSession.workingDir,
mode: muxSession.mode,
name: sessionName,
mux: this.mux,
useMux: true,
muxSession: screen // Pass the existing session so startInteractive() can attach to it
muxSession: muxSession // 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.mux.updateSessionName(screen.sessionId, savedState.name);
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
if (savedState?.name && muxSession.name !== savedState.name) {
this.mux.updateSessionName(muxSession.sessionId, savedState.name);
}
if (savedState) {
// Auto-compact
@@ -5263,18 +5263,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
}
// Fallback: restore respawn from screens.json if state.json didn't have it
if (!this.respawnControllers.has(session.id) && screen.respawnConfig?.enabled) {
// Fallback: restore respawn from mux-sessions.json if state.json didn't have it
if (!this.respawnControllers.has(session.id) && muxSession.respawnConfig?.enabled) {
try {
this.restoreRespawnController(session, screen.respawnConfig, 'screens.json');
this.restoreRespawnController(session, muxSession.respawnConfig, 'mux-sessions.json');
} catch (err) {
console.error(`[Server] Failed to restore respawn from screens.json for session ${session.id}:`, err);
console.error(`[Server] Failed to restore respawn from mux-sessions.json for session ${session.id}:`, err);
}
}
// Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled
if (!session.ralphTracker.enabled && !session.ralphTracker.autoEnableDisabled) {
const ralphState = this.store.getRalphState(screen.sessionId);
const ralphState = this.store.getRalphState(muxSession.sessionId);
if (ralphState?.loop?.enabled) {
session.ralphTracker.restoreState(ralphState.loop, ralphState.todos);
console.log(`[Server] Restored Ralph state from inner store for session ${session.id}`);
@@ -5296,16 +5296,16 @@ 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 mux ${screen.muxName}`);
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
}
}
// Start stats collection to show screen info
// Start stats collection for mux sessions
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
// Start mouse mode sync (tmux only) — toggles mouse on/off based on pane count.
// Mouse off = native xterm.js selection; mouse on = tmux pane clicking (split layouts).
if (this.mux.backend === 'tmux' && 'startMouseModeSync' in this.mux) {
if ('startMouseModeSync' in this.mux) {
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
}
}
@@ -5415,7 +5415,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
);
// Properly clean up all remaining sessions in parallel (removes listeners, clears state, etc.)
// Don't kill screens on server stop - they can be reattached on restart
// Don't kill mux sessions on server stop - they can be reattached on restart
// Use Promise.race with a 30s timeout to prevent shutdown from hanging indefinitely
const sessionCleanup = Promise.allSettled(
Array.from(this.sessions.keys()).map(id => this.cleanupSession(id, false))