diff --git a/CLAUDE.md b/CLAUDE.md index 3460c271..5eb56f8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1484 (must match `package.json` for npm publish) +**Version**: 0.1485 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 9f94b1f3..be8bf04b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1484", + "version": "0.1485", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/scripts/tmux-manager.sh b/scripts/tmux-manager.sh index b4ee1f24..1353603b 100755 --- a/scripts/tmux-manager.sh +++ b/scripts/tmux-manager.sh @@ -115,6 +115,13 @@ kill_session() { local mux_name=$(get_session_field $idx "muxName") local pid=$(get_session_field $idx "pid") + # SAFETY: Never kill own tmux session + local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") + if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then + echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}" + return 1 + fi + pkill -TERM -P $pid 2>/dev/null kill -TERM -$pid 2>/dev/null tmux kill-session -t "$mux_name" 2>/dev/null @@ -489,8 +496,19 @@ main() { ;; kill-all) force_refresh - for ((i=CACHED_COUNT-1; i>=0; i--)); do kill_session $i; done - echo "All sessions killed" + # SAFETY: Never kill own tmux session + local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") + local killed=0 + for ((i=CACHED_COUNT-1; i>=0; i--)); do + local mux_name=$(get_session_field $i "muxName") + if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then + echo -e "${RED}SKIPPED: Own tmux session: $mux_name${NC}" + continue + fi + kill_session $i + ((killed++)) + done + echo "$killed sessions killed" ;; info) [[ -z "${2:-}" ]] && { echo "Usage: $0 info "; exit 1; } diff --git a/src/screen-manager.ts b/src/screen-manager.ts index e27463c1..44f4008b 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -57,14 +57,10 @@ const DEFAULT_STATS_INTERVAL_MS = 2000; /** Maximum retry attempts for carriage return (3) */ const CR_MAX_ATTEMPTS = 3; -/** - * Wraps a command with `nice` for priority adjustment. - */ -export function wrapWithNice(cmd: string, config: NiceConfig): string { - if (!config.enabled) return cmd; - const niceValue = Math.max(-20, Math.min(19, config.niceValue)); - return `nice -n ${niceValue} ${cmd}`; -} +// Import from shared utility (extracted to avoid cross-dependency) +import { wrapWithNice } from './utils/nice-wrapper.js'; +// Re-export for backward compatibility +export { wrapWithNice } from './utils/nice-wrapper.js'; /** Cached directory containing the claude binary */ diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 768b16c3..1eaf97f5 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -27,7 +27,7 @@ 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 { wrapWithNice } from './utils/nice-wrapper.js'; import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js'; // ============================================================================ @@ -156,6 +156,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { private sessions: Map = new Map(); private statsInterval: NodeJS.Timeout | null = null; + private trueColorConfigured = false; + constructor() { super(); this.loadSessions(); @@ -296,6 +298,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Non-critical — session still works with status bar } + // Enable 24-bit true color passthrough — without this, tmux downgrades + // RGB colors (like Claude's red logo) to the nearest 256-color palette entry. + // Server-wide option, only set once per TmuxManager lifetime to avoid duplicates. + if (!this.trueColorConfigured) { + try { + execSync( + `tmux set-option -sa terminal-overrides ",*:Tc"`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + this.trueColorConfigured = true; + } catch { + // Non-critical — colors will still work, just limited to 256 + } + } + // Get the PID of the pane process const pid = this.getPanePid(muxName); if (!pid) { @@ -419,6 +436,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return false; } + // SAFETY: Never kill the tmux session we're running inside of + const currentMuxName = process.env.CLAUDEMAN_SCREEN_NAME; + if (currentMuxName && session.muxName === currentMuxName) { + console.error(`[TmuxManager] BLOCKED: Refusing to kill own tmux session: ${session.muxName}`); + return false; + } + // Get current PID (may have changed) const currentPid = this.getPanePid(session.muxName) || session.pid; diff --git a/src/utils/index.ts b/src/utils/index.ts index 4ac32420..8f1066e0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -34,3 +34,4 @@ export { todoContentHash, } from './string-similarity.js'; export { assertNever } from './type-safety.js'; +export { wrapWithNice } from './nice-wrapper.js'; diff --git a/src/utils/nice-wrapper.ts b/src/utils/nice-wrapper.ts new file mode 100644 index 00000000..9655844f --- /dev/null +++ b/src/utils/nice-wrapper.ts @@ -0,0 +1,19 @@ +/** + * @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. + * + * @module utils/nice-wrapper + */ + +import type { NiceConfig } from '../types.js'; + +/** + * Wraps a command with `nice` for priority adjustment. + */ +export function wrapWithNice(cmd: string, config: NiceConfig): string { + if (!config.enabled) return cmd; + const niceValue = Math.max(-20, Math.min(19, config.niceValue)); + return `nice -n ${niceValue} ${cmd}`; +} diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 1f639c3a..6da88da2 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -1,8 +1,8 @@ /** - * @fileoverview Unit tests for TmuxManager + * @fileoverview Unit + integration tests for TmuxManager * - * Tests validation functions, command construction, and parsing logic - * using mocked exec calls. Does NOT create or kill real tmux sessions. + * Unit tests (mocked): validation, command construction, parsing logic. + * Integration tests (real tmux): session creation, input, kill, reconciliation. * * Port: N/A (no server needed) */ @@ -10,6 +10,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { TmuxManager } from '../src/tmux-manager.js'; import { execSync } from 'node:child_process'; +import { registerTestTmuxSession, unregisterTestTmuxSession } from './setup.js'; + +// ============================================================================ +// Unit Tests (mocked) +// ============================================================================ // Mock child_process vi.mock('node:child_process', async () => { @@ -37,7 +42,7 @@ vi.mock('node:fs', async () => { }; }); -describe('TmuxManager', () => { +describe('TmuxManager (unit)', () => { let manager: TmuxManager; const mockedExecSync = vi.mocked(execSync); @@ -299,6 +304,97 @@ describe('TmuxManager', () => { }); }); + describe('killSession self-kill protection', () => { + it('should block kill when session matches CLAUDEMAN_SCREEN_NAME', async () => { + const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME; + process.env.CLAUDEMAN_SCREEN_NAME = 'claudeman-5e1f1111'; + + try { + manager.registerSession({ + sessionId: 'self-kill-test', + muxName: 'claudeman-5e1f1111', + pid: 999, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + + const result = await manager.killSession('self-kill-test'); + expect(result).toBe(false); + + // Session should still exist (not removed) + expect(manager.getSession('self-kill-test')).toBeDefined(); + } finally { + if (originalEnv === undefined) { + delete process.env.CLAUDEMAN_SCREEN_NAME; + } else { + process.env.CLAUDEMAN_SCREEN_NAME = originalEnv; + } + } + }); + + it('should allow kill when session does NOT match CLAUDEMAN_SCREEN_NAME', async () => { + const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME; + process.env.CLAUDEMAN_SCREEN_NAME = 'claudeman-0ther1111'; + + try { + manager.registerSession({ + sessionId: 'other-kill-test', + muxName: 'claudeman-d1ff1111', + pid: 888, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + + // Mock the kill flow + mockedExecSync.mockImplementation(() => ''); + + const result = await manager.killSession('other-kill-test'); + expect(result).toBe(true); + + // Session should be removed + expect(manager.getSession('other-kill-test')).toBeUndefined(); + } finally { + if (originalEnv === undefined) { + delete process.env.CLAUDEMAN_SCREEN_NAME; + } else { + process.env.CLAUDEMAN_SCREEN_NAME = originalEnv; + } + } + }); + + it('should allow kill when CLAUDEMAN_SCREEN_NAME is not set', async () => { + const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME; + delete process.env.CLAUDEMAN_SCREEN_NAME; + + try { + manager.registerSession({ + sessionId: 'no-env-test', + muxName: 'claudeman-aaa11111', + pid: 777, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + + mockedExecSync.mockImplementation(() => ''); + + const result = await manager.killSession('no-env-test'); + expect(result).toBe(true); + } finally { + if (originalEnv === undefined) { + delete process.env.CLAUDEMAN_SCREEN_NAME; + } else { + process.env.CLAUDEMAN_SCREEN_NAME = originalEnv; + } + } + }); + }); + describe('metadata operations', () => { beforeEach(() => { manager.registerSession({ @@ -385,3 +481,185 @@ describe('TmuxManager', () => { }); }); }); + +// ============================================================================ +// Integration Tests (real tmux sessions) +// ============================================================================ + +describe('TmuxManager (integration)', () => { + // Skip entire block if tmux is not available + const tmuxAvailable = (() => { + try { + const { execSync: realExecSync } = require('node:child_process'); + realExecSync('which tmux', { encoding: 'utf-8', timeout: 5000 }); + return true; + } catch { + return false; + } + })(); + + if (!tmuxAvailable) { + it.skip('tmux not available — skipping integration tests', () => {}); + return; + } + + // Real execSync for integration tests (bypasses mock) + const { execSync: realExecSync } = require('node:child_process') as typeof import('node:child_process'); + + // Helper: create a test tmux session directly via tmux CLI + function createRawTmuxSession(name: string): void { + realExecSync(`tmux new-session -ds "${name}" -x 80 -y 24 bash`, { timeout: 5000 }); + registerTestTmuxSession(name); + } + + // Helper: check if tmux session exists + function tmuxSessionExists(name: string): boolean { + try { + realExecSync(`tmux has-session -t "${name}" 2>/dev/null`, { timeout: 5000 }); + return true; + } catch { + return false; + } + } + + // Helper: kill a test tmux session directly + function killRawTmuxSession(name: string): void { + try { + realExecSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 5000 }); + } catch { + // May already be dead + } + unregisterTestTmuxSession(name); + } + + // Track sessions created during integration tests for cleanup + const createdSessions: string[] = []; + + afterEach(() => { + // Clean up any sessions created during the test + for (const name of createdSessions) { + killRawTmuxSession(name); + } + createdSessions.length = 0; + }); + + it('should create a real tmux session', () => { + const sessionName = 'claudeman-test-create'; + createRawTmuxSession(sessionName); + createdSessions.push(sessionName); + + expect(tmuxSessionExists(sessionName)).toBe(true); + }); + + it('should send input to a real tmux session and verify output', async () => { + const sessionName = 'claudeman-test-input'; + createRawTmuxSession(sessionName); + createdSessions.push(sessionName); + + // Send text to the session + realExecSync(`tmux send-keys -t "${sessionName}" -l 'echo TMUX_INPUT_TEST_OK'`, { timeout: 5000 }); + realExecSync(`tmux send-keys -t "${sessionName}" Enter`, { timeout: 5000 }); + + // Wait for command to execute + await new Promise(resolve => setTimeout(resolve, 500)); + + // Capture pane contents + const output = realExecSync(`tmux capture-pane -t "${sessionName}" -p`, { encoding: 'utf-8', timeout: 5000 }); + expect(output).toContain('TMUX_INPUT_TEST_OK'); + }); + + it('should kill a real tmux session', () => { + const sessionName = 'claudeman-test-kill'; + createRawTmuxSession(sessionName); + // Don't push to createdSessions since we'll kill it manually + + expect(tmuxSessionExists(sessionName)).toBe(true); + + realExecSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 5000 }); + unregisterTestTmuxSession(sessionName); + + expect(tmuxSessionExists(sessionName)).toBe(false); + }); + + it('should discover unknown claudeman sessions via reconcile', async () => { + // Create a tmux session directly (not via TmuxManager) — simulates a "ghost" + const sessionName = 'claudeman-te51abcd'; + createRawTmuxSession(sessionName); + createdSessions.push(sessionName); + + // existsSync is already mocked to return false (module-level mock), + // so TmuxManager won't load any persisted sessions from disk + const freshManager = new TmuxManager(); + + // Verify it doesn't know about the session yet + expect(freshManager.getSessions()).toHaveLength(0); + + // Note: Full reconcile with real tmux requires unmocked execSync, + // which is covered by the tmux-restart-recovery.test.ts integration tests. + freshManager.destroy(); + }); + + it('should verify self-kill protection with real env var', async () => { + const sessionName = 'claudeman-te515e1f'; + createRawTmuxSession(sessionName); + createdSessions.push(sessionName); + + const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME; + process.env.CLAUDEMAN_SCREEN_NAME = sessionName; + + try { + // existsSync is already mocked to return false (module-level mock) + const testManager = new TmuxManager(); + testManager.registerSession({ + sessionId: 'self-test', + muxName: sessionName, + pid: 99999, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + + // killSession should refuse + const result = await testManager.killSession('self-test'); + expect(result).toBe(false); + + // Session should still be alive in tmux + expect(tmuxSessionExists(sessionName)).toBe(true); + + testManager.destroy(); + } finally { + if (originalEnv === undefined) { + delete process.env.CLAUDEMAN_SCREEN_NAME; + } else { + process.env.CLAUDEMAN_SCREEN_NAME = originalEnv; + } + } + }); + + it('should persist and load session metadata', () => { + // This test verifies the persistence format is correct by checking + // that registerSession + getSessions round-trips properly + // existsSync is already mocked to return false (module-level mock) + const manager1 = new TmuxManager(); + manager1.registerSession({ + sessionId: 'persist-test', + muxName: 'claudeman-be51aaa1', + pid: 12345, + createdAt: 1700000000000, + workingDir: '/home/test', + mode: 'claude', + attached: false, + name: 'Test Session', + respawnConfig: { enabled: true, idleTimeoutMs: 5000, updatePrompt: 'test', interStepDelayMs: 1000, sendClear: true, sendInit: true }, + }); + + const sessions = manager1.getSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionId).toBe('persist-test'); + expect(sessions[0].name).toBe('Test Session'); + expect(sessions[0].respawnConfig?.enabled).toBe(true); + + manager1.destroy(); + }); +}); diff --git a/test/tmux-restart-recovery.test.ts b/test/tmux-restart-recovery.test.ts new file mode 100644 index 00000000..c68cf8b4 --- /dev/null +++ b/test/tmux-restart-recovery.test.ts @@ -0,0 +1,247 @@ +/** + * @fileoverview Integration test for tmux session recovery after server restart. + * + * Tests the full restart cycle: + * 1. Create a WebServer with real tmux backend + * 2. Create a session (tmux) + * 3. Stop the server (without killing tmux sessions) + * 4. Start a new server instance + * 5. Verify session is recovered via reconcileSessions() + * + * Port: 3152 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { registerTestTmuxSession, unregisterTestTmuxSession } from './setup.js'; + +// Skip all tests if tmux is not available +const tmuxAvailable = (() => { + try { + execSync('which tmux', { encoding: 'utf-8', timeout: 5000 }); + return true; + } catch { + return false; + } +})(); + +const MUX_SESSIONS_FILE = join(homedir(), '.claudeman', 'mux-sessions.json'); + +describe.skipIf(!tmuxAvailable)('TmuxManager restart recovery', () => { + // Track tmux sessions for cleanup + const createdTmuxSessions: string[] = []; + let originalMuxSessions: string | null = null; + + beforeEach(() => { + // Back up the current mux-sessions.json if it exists + if (existsSync(MUX_SESSIONS_FILE)) { + originalMuxSessions = readFileSync(MUX_SESSIONS_FILE, 'utf-8'); + } + }); + + afterEach(async () => { + // Kill all tmux sessions created during tests + for (const name of createdTmuxSessions) { + try { + execSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 5000 }); + } catch { + // May already be dead + } + unregisterTestTmuxSession(name); + } + createdTmuxSessions.length = 0; + + // Restore original mux-sessions.json + if (originalMuxSessions !== null) { + writeFileSync(MUX_SESSIONS_FILE, originalMuxSessions); + } + + // Brief wait for cleanup + await new Promise(resolve => setTimeout(resolve, 200)); + }); + + it('should recover a tmux session after manager restart', async () => { + // Step 1: Create a tmux session directly (simulating a session created by old server) + const sessionName = 'claudeman-de51ecaf'; + execSync(`tmux new-session -ds "${sessionName}" -x 80 -y 24 bash`, { timeout: 5000 }); + registerTestTmuxSession(sessionName); + createdTmuxSessions.push(sessionName); + + // Step 2: Write session metadata to mux-sessions.json (as if old server persisted it) + const dir = join(homedir(), '.claudeman'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const sessionData = [{ + sessionId: 'test-recovery-1', + muxName: sessionName, + pid: 1, // Stale PID — will be updated during reconcile + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + name: 'Recovery Test', + respawnConfig: { enabled: true, idleTimeoutMs: 10000, updatePrompt: 'continue', interStepDelayMs: 2000, sendClear: false, sendInit: true }, + }]; + writeFileSync(MUX_SESSIONS_FILE, JSON.stringify(sessionData, null, 2)); + + // Step 3: Create a new TmuxManager (simulates server restart — loads from file) + const manager = new TmuxManager(); + + // Verify session was loaded from disk + const loaded = manager.getSession('test-recovery-1'); + expect(loaded).toBeDefined(); + expect(loaded!.muxName).toBe(sessionName); + expect(loaded!.name).toBe('Recovery Test'); + expect(loaded!.respawnConfig?.enabled).toBe(true); + + // Step 4: Reconcile — should detect the tmux session is alive + const result = await manager.reconcileSessions(); + expect(result.alive).toContain('test-recovery-1'); + expect(result.dead).toHaveLength(0); + + // PID should be updated to actual tmux pane PID + const reconciled = manager.getSession('test-recovery-1'); + expect(reconciled).toBeDefined(); + expect(reconciled!.pid).toBeGreaterThan(1); // Updated from stale PID + + manager.destroy(); + }); + + it('should detect dead sessions during reconcile', async () => { + // Write metadata for a session that doesn't actually exist in tmux + const dir = join(homedir(), '.claudeman'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const sessionData = [{ + sessionId: 'test-dead-1', + muxName: 'claudeman-deadbeef', + pid: 99999, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + name: 'Dead Session', + }]; + writeFileSync(MUX_SESSIONS_FILE, JSON.stringify(sessionData, null, 2)); + + const manager = new TmuxManager(); + + // Verify session was loaded + expect(manager.getSession('test-dead-1')).toBeDefined(); + + // Reconcile — should detect the session is dead + const result = await manager.reconcileSessions(); + expect(result.dead).toContain('test-dead-1'); + expect(result.alive).not.toContain('test-dead-1'); + + // Session should be removed after reconcile + expect(manager.getSession('test-dead-1')).toBeUndefined(); + + manager.destroy(); + }); + + it('should discover ghost sessions not in metadata', async () => { + // Create a tmux session directly (not via TmuxManager, no metadata) + const sessionName = 'claudeman-ab12ef34'; + execSync(`tmux new-session -ds "${sessionName}" -x 80 -y 24 bash`, { timeout: 5000 }); + registerTestTmuxSession(sessionName); + createdTmuxSessions.push(sessionName); + + // Start with empty metadata + const dir = join(homedir(), '.claudeman'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(MUX_SESSIONS_FILE, '[]'); + + const manager = new TmuxManager(); + + // No sessions loaded + expect(manager.getSessions()).toHaveLength(0); + + // Reconcile should discover the ghost session + const result = await manager.reconcileSessions(); + expect(result.discovered.length).toBeGreaterThanOrEqual(1); + + // Find the discovered session + const discoveredId = result.discovered.find(id => id === 'restored-ab12ef34'); + expect(discoveredId).toBeDefined(); + + // Verify the discovered session has correct metadata + const session = manager.getSession(discoveredId!); + expect(session).toBeDefined(); + expect(session!.muxName).toBe(sessionName); + expect(session!.mode).toBe('claude'); + expect(session!.pid).toBeGreaterThan(0); + + manager.destroy(); + }); + + it('should handle mixed alive, dead, and ghost sessions', async () => { + // Create a real tmux session (will be "alive") + const aliveSessionName = 'claudeman-a11eeaaa'; + execSync(`tmux new-session -ds "${aliveSessionName}" -x 80 -y 24 bash`, { timeout: 5000 }); + registerTestTmuxSession(aliveSessionName); + createdTmuxSessions.push(aliveSessionName); + + // Create a ghost session (real tmux, no metadata) + const ghostSessionName = 'claudeman-ab05fabf'; + execSync(`tmux new-session -ds "${ghostSessionName}" -x 80 -y 24 bash`, { timeout: 5000 }); + registerTestTmuxSession(ghostSessionName); + createdTmuxSessions.push(ghostSessionName); + + // Write metadata with alive + dead sessions + const dir = join(homedir(), '.claudeman'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const sessionData = [ + { + sessionId: 'alive-session', + muxName: aliveSessionName, + pid: 1, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + name: 'Alive Session', + }, + { + sessionId: 'dead-session', + muxName: 'claudeman-dead0000', + pid: 99999, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + name: 'Dead Session', + }, + ]; + writeFileSync(MUX_SESSIONS_FILE, JSON.stringify(sessionData, null, 2)); + + const manager = new TmuxManager(); + const result = await manager.reconcileSessions(); + + expect(result.alive).toContain('alive-session'); + expect(result.dead).toContain('dead-session'); + expect(result.discovered).toContain('restored-ab05fabf'); + + // Verify final session state + const sessions = manager.getSessions(); + const sessionIds = sessions.map(s => s.sessionId); + expect(sessionIds).toContain('alive-session'); + expect(sessionIds).not.toContain('dead-session'); + expect(sessionIds).toContain('restored-ab05fabf'); + + manager.destroy(); + }); +});