From d5ae376e07f2731ba40df65fa7b12092cfd65cd2 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 21 Jan 2026 09:54:42 +0100 Subject: [PATCH] feat(tui): add web server auto-start, shell mode, and feature parity TUI now checks if web server is running on startup and offers to start it in the background. Added new CLI options: --with-web (auto-start), --no-web (skip check), -p (port). TUI feature parity with web interface: - Shell mode: press 'h' in cases view to start bash instead of Claude - Multi-start: press 'm' to start 1-20 sessions at once - Respawn toggle: Ctrl+R to enable/disable respawn on Claude sessions - Session rename: API support via useSessionManager hook Security fixes from previous analysis: - Command injection prevention in screen-manager.ts - Path traversal protection in server.ts - Input validation for shell-interpolated values Also fixes memory leak in session.ts (timer tracking) and flaky test timeout in session-cleanup.test.ts. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 47 ++- src/cli.ts | 12 +- src/screen-manager.ts | 78 +++- src/session.ts | 100 ++++- src/tui/App.tsx | 123 +++++- src/tui/components/HelpOverlay.tsx | 22 +- src/tui/components/StartScreen.tsx | 606 +++++++++++++++++++++++------ src/tui/hooks/useSessionManager.ts | 226 ++++++++++- src/tui/index.tsx | 137 ++++++- src/types.ts | 2 + src/web/server.ts | 61 ++- test/session-cleanup.test.ts | 2 +- todo.md | 34 ++ 13 files changed, 1258 insertions(+), 192 deletions(-) create mode 100644 todo.md diff --git a/CLAUDE.md b/CLAUDE.md index 4062d88f..d77708af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,8 +43,11 @@ node dist/index.js web # After npm run build claudeman web # After npm link # Start TUI (terminal user interface): -npx tsx src/index.ts tui # Dev mode - full-screen TUI +npx tsx src/index.ts tui # Dev mode - prompts to start web if not running claudeman tui # After npm link +claudeman tui --with-web # Auto-start web server if not running (no prompt) +claudeman tui --no-web # Skip web server check entirely +claudeman tui -p 8080 # Specify web server port # Testing (vitest) # Note: globals: true configured - no imports needed for describe/it/expect @@ -414,12 +417,12 @@ npx agent-browser close | GET | `/api/sessions/:id/inner-state` | Get Ralph loop state + todos | | POST | `/api/sessions/:id/auto-compact` | Configure auto-compact threshold | | POST | `/api/sessions/:id/auto-clear` | Configure auto-clear threshold | -| POST | `/api/quick-start` | Create case + start interactive session | +| POST | `/api/quick-start` | Create case + start session (claude or shell mode) | | GET | `/api/cases` | List available cases | | POST | `/api/cases` | Create new case | | GET | `/api/screens` | List screen sessions with stats | -## Keyboard Shortcuts +## Keyboard Shortcuts (Web UI) | Shortcut | Action | |----------|--------| @@ -432,6 +435,44 @@ npx agent-browser close | `Ctrl+?` | Show keyboard shortcuts help | | `Escape` | Close panels and modals | +## TUI Keyboard Shortcuts + +**Start Screen - Sessions:** +| Key | Action | +|-----|--------| +| `↑`/`↓` | Navigate list | +| `Enter` | View session in TUI | +| `a` | Attach directly to screen (full terminal) | +| `d` | Delete/kill selected session | +| `c` | Switch to cases view | +| `n` | Quick-start new session | +| `r` | Refresh list | +| `q` | Quit TUI | + +**Start Screen - Cases:** +| Key | Action | +|-----|--------| +| `↑`/`↓` | Navigate list | +| `Enter` | Start Claude session with selected case | +| `h` | Start Shell session with selected case | +| `m` | Multi-start (1-20 sessions at once) | +| `n` | Create new case | +| `s` | Switch to sessions view | +| `r` | Refresh list | + +**Main View (Session Active):** +| Key | Action | +|-----|--------| +| `Tab`/`Shift+Tab` | Next/previous session tab | +| `Ctrl+1-9` | Go to session N | +| `[`/`]` | Previous/next session (vim-style) | +| `Ctrl+N` | New session | +| `Ctrl+W` | Close current session | +| `Ctrl+K` | Kill all sessions | +| `Ctrl+R` | Toggle respawn (Claude sessions only) | +| `Escape` | Back to start screen | +| `?` | Toggle help overlay | + ## State Files | File | Purpose | diff --git a/src/cli.ts b/src/cli.ts index afb5d813..7fc4c203 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -483,10 +483,18 @@ program program .command('tui') .description('Start the terminal user interface') - .action(async () => { + .option('--with-web', 'Automatically start the web server if not running (no prompt)') + .option('--no-web', 'Skip web server check entirely') + .option('-p, --port ', 'Web server port (default: 3000)', '3000') + .action(async (options: { withWeb?: boolean; web?: boolean; port?: string }) => { try { const { startTUI } = await import('./tui/index.js'); - await startTUI(); + const port = parseInt(options.port || '3000', 10); + await startTUI({ + autoStartWeb: options.withWeb || false, + skipWebCheck: options.web === false, + port, + }); } catch (err) { console.error(chalk.red(`✗ Failed to start TUI: ${(err as Error).message}`)); process.exit(1); diff --git a/src/screen-manager.ts b/src/screen-manager.ts index ea20a2a4..98df15ea 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -27,6 +27,56 @@ const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json'); /** Pre-compiled regex for parsing `screen -ls` output */ const SCREEN_PATTERN = /(\d+)\.(claudeman-([a-f0-9-]+))/g; +/** Regex to validate screen names (only allow safe characters) */ +const SAFE_SCREEN_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 screen name contains only safe characters. + * Prevents command injection via malformed session IDs. + * + * @param name - The screen name to validate + * @returns true if the name is safe for use in shell commands + */ +function isValidScreenName(name: string): boolean { + return SAFE_SCREEN_NAME_PATTERN.test(name); +} + +/** + * Validates that a path contains only safe characters. + * Prevents command injection via malformed paths. + * + * @param path - The path to validate + * @returns true if the path is safe for use in shell commands + */ +function isValidPath(path: string): boolean { + // Check for shell metacharacters that could lead to injection + 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; + } + return SAFE_PATH_PATTERN.test(path); +} + +/** + * Escapes a string for safe use in shell double quotes. + * + * @param str - The string to escape + * @returns The escaped string + */ +function shellEscape(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$/g, '\\$') + .replace(/`/g, '\\`'); +} + /** * Manages GNU Screen sessions that wrap Claude CLI or shell processes. * @@ -107,6 +157,14 @@ export class ScreenManager extends EventEmitter { async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell', name?: string): Promise { const screenName = `claudeman-${sessionId.slice(0, 8)}`; + // Security: Validate screenName and workingDir to prevent command injection + if (!isValidScreenName(screenName)) { + throw new Error(`Invalid screen name: contains unsafe characters`); + } + if (!isValidPath(workingDir)) { + throw new Error(`Invalid working directory path: contains unsafe characters`); + } + // Create screen in detached mode with the appropriate command // Set CLAUDEMAN_SCREEN=1 so Claude sessions know they're running in Claudeman // This helps prevent Claude from attempting to kill its own screen session @@ -161,8 +219,16 @@ export class ScreenManager extends EventEmitter { // Get screen session PID private getScreenPid(screenName: string): number | null { + // Security: Validate screenName to prevent command injection + if (!isValidScreenName(screenName)) { + console.error('[ScreenManager] Invalid screen name in getScreenPid:', screenName); + return null; + } + try { - const output = execSync(`screen -ls | grep "${screenName}"`, { + // Use shell-escaped screenName in grep + const escapedName = shellEscape(screenName); + const output = execSync(`screen -ls | grep "${escapedName}"`, { encoding: 'utf-8', timeout: 5000 }); @@ -522,6 +588,12 @@ export class ScreenManager extends EventEmitter { return false; } + // Security: Validate screenName to prevent command injection + if (!isValidScreenName(screen.screenName)) { + console.error('[ScreenManager] Invalid screen name in sendInput:', screen.screenName); + return false; + } + try { // Split input into text and control characters // IMPORTANT: Must send text and carriage return as SEPARATE commands @@ -529,8 +601,8 @@ export class ScreenManager extends EventEmitter { const hasCarriageReturn = input.includes('\r'); const textPart = input.replace(/\r/g, '').replace(/\n/g, ''); - // Escape the text part for shell (double quotes) - const escapedText = textPart.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`'); + // Escape the text part for shell using the helper function + const escapedText = shellEscape(textPart); // Send text first (if any) if (escapedText) { diff --git a/src/session.ts b/src/session.ts index 4a423338..fb9449df 100644 --- a/src/session.ts +++ b/src/session.ts @@ -308,6 +308,13 @@ export class Session extends EventEmitter { private _autoCompactPrompt: string = ''; // Optional prompt for compact private _isCompacting: boolean = false; // Prevent recursive compacting + // Timer tracking for cleanup (prevents memory leaks) + private _autoCompactTimer: NodeJS.Timeout | null = null; + private _autoClearTimer: NodeJS.Timeout | null = null; + private _promptCheckInterval: NodeJS.Timeout | null = null; + 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; @@ -645,11 +652,18 @@ export class Session extends EventEmitter { // 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 if (!isRestoredSession) { - const checkForPrompt = setInterval(() => { + this._promptCheckInterval = setInterval(() => { // Wait for the prompt character (❯) which means Claude is fully initialized const bufferValue = this._terminalBuffer.value; if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) { - clearInterval(checkForPrompt); + if (this._promptCheckInterval) { + clearInterval(this._promptCheckInterval); + this._promptCheckInterval = null; + } + if (this._promptCheckTimeout) { + clearTimeout(this._promptCheckTimeout); + this._promptCheckTimeout = null; + } // Clean the buffer - remove screen 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 @@ -661,7 +675,13 @@ export class Session extends EventEmitter { } }, 50); // Timeout after 5 seconds if prompt not found - setTimeout(() => clearInterval(checkForPrompt), 5000); + this._promptCheckTimeout = setTimeout(() => { + if (this._promptCheckInterval) { + clearInterval(this._promptCheckInterval); + this._promptCheckInterval = null; + } + this._promptCheckTimeout = null; + }, 5000); } } catch (err) { console.error('[Session] Failed to create screen session, falling back to direct PTY:', err); @@ -745,6 +765,19 @@ export class Session extends EventEmitter { this.ptyProcess = null; this._pid = null; this._status = 'idle'; + // Clear all timers to prevent memory leaks + if (this.activityTimeout) { + clearTimeout(this.activityTimeout); + this.activityTimeout = null; + } + if (this._promptCheckInterval) { + clearInterval(this._promptCheckInterval); + this._promptCheckInterval = null; + } + if (this._promptCheckTimeout) { + 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); @@ -867,6 +900,15 @@ export class Session extends EventEmitter { this.ptyProcess = null; this._pid = null; this._status = 'idle'; + // Clear timers to prevent memory leaks + if (this._shellIdleTimer) { + clearTimeout(this._shellIdleTimer); + this._shellIdleTimer = null; + } + if (this.activityTimeout) { + 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); @@ -875,7 +917,8 @@ export class Session extends EventEmitter { }); // Mark as idle after a short delay (shell is ready) - setTimeout(() => { + this._shellIdleTimer = setTimeout(() => { + this._shellIdleTimer = null; this._status = 'idle'; this._isWorking = false; this.emit('idle'); @@ -1151,6 +1194,9 @@ export class Session extends EventEmitter { // Wait for Claude to be idle before compacting const checkAndCompact = () => { + // Check if session is still valid (not stopped) + if (!this._isCompacting) return; + if (!this._isWorking) { // Send /compact command with optional prompt const compactCmd = this._autoCompactPrompt @@ -1164,17 +1210,18 @@ export class Session extends EventEmitter { }); // Wait a moment then re-enable (longer than clear since compact takes time) - setTimeout(() => { + this._autoCompactTimer = setTimeout(() => { + this._autoCompactTimer = null; this._isCompacting = false; }, 10000); } else { // Check again in 2 seconds - setTimeout(checkAndCompact, 2000); + this._autoCompactTimer = setTimeout(checkAndCompact, 2000); } }; // Start checking after a short delay - setTimeout(checkAndCompact, 1000); + this._autoCompactTimer = setTimeout(checkAndCompact, 1000); } } @@ -1189,6 +1236,9 @@ export class Session extends EventEmitter { // Wait for Claude to be idle before clearing const checkAndClear = () => { + // Check if session is still valid (not stopped) + if (!this._isClearing) return; + if (!this._isWorking) { // Send /clear command this.writeViaScreen('/clear\r'); @@ -1198,17 +1248,18 @@ export class Session extends EventEmitter { this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold }); // Wait a moment then re-enable - setTimeout(() => { + this._autoClearTimer = setTimeout(() => { + this._autoClearTimer = null; this._isClearing = false; }, 5000); } else { // Check again in 2 seconds - setTimeout(checkAndClear, 2000); + this._autoClearTimer = setTimeout(checkAndClear, 2000); } }; // Start checking after a short delay - setTimeout(checkAndClear, 1000); + this._autoClearTimer = setTimeout(checkAndClear, 1000); } } @@ -1342,6 +1393,35 @@ export class Session extends EventEmitter { this._lineBufferFlushTimer = null; } + // Clear auto-compact/auto-clear timers to prevent memory leaks + if (this._autoCompactTimer) { + clearTimeout(this._autoCompactTimer); + this._autoCompactTimer = null; + } + this._isCompacting = false; + + if (this._autoClearTimer) { + clearTimeout(this._autoClearTimer); + this._autoClearTimer = null; + } + this._isClearing = false; + + // Clear prompt check timers + if (this._promptCheckInterval) { + clearInterval(this._promptCheckInterval); + this._promptCheckInterval = null; + } + if (this._promptCheckTimeout) { + clearTimeout(this._promptCheckTimeout); + this._promptCheckTimeout = null; + } + + // Clear shell idle timer + if (this._shellIdleTimer) { + clearTimeout(this._shellIdleTimer); + this._shellIdleTimer = null; + } + // Immediately cleanup Promise callbacks to prevent orphaned references // during the rest of stop() processing (e.g., if screen kill times out) if (this.rejectPromise) { diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 9aaa8ad8..6b3a5187 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -68,8 +68,10 @@ export function App(): React.ReactElement { activeSessionId, activeSession, refreshSessions, + refreshCases, selectSession, createSession, + createCase, killSession, killAllSessions, nextSession, @@ -79,6 +81,9 @@ export function App(): React.ReactElement { innerLoopState, innerTodos, respawnStatus, + cases, + toggleRespawn, + renameSession, } = useSessionManager(); // Calculate terminal height based on stdout dimensions @@ -112,7 +117,7 @@ export function App(): React.ReactElement { return; } - // Exit on Ctrl+C (with confirmation in main view) + // Exit on Ctrl+C if (key.ctrl && input === 'c') { exit(); return; @@ -129,7 +134,19 @@ export function App(): React.ReactElement { return; } - // Main view shortcuts + // === SESSION SWITCHING SHORTCUTS === + + // Tab / Shift+Tab to switch sessions (most intuitive) + if (key.tab) { + if (key.shift) { + prevSession(); + } else { + nextSession(); + } + return; + } + + // Ctrl+Tab / Ctrl+Shift+Tab (if terminal supports it) if (key.ctrl && key.tab) { if (key.shift) { prevSession(); @@ -139,8 +156,28 @@ export function App(): React.ReactElement { return; } - // Ctrl+1-9 for direct tab access - if (key.ctrl) { + // Alt+Left/Right arrow keys for session switching + if (key.meta && key.leftArrow) { + prevSession(); + return; + } + if (key.meta && key.rightArrow) { + nextSession(); + return; + } + + // [ and ] for previous/next session (vim-like) + if (input === '[' && !key.ctrl && !key.meta) { + prevSession(); + return; + } + if (input === ']' && !key.ctrl && !key.meta) { + nextSession(); + return; + } + + // Alt+1-9 or Ctrl+1-9 for direct tab access + if (key.ctrl || key.meta) { const num = parseInt(input, 10); if (!isNaN(num) && num >= 1 && num <= 9 && num <= sessions.length) { selectSession(sessions[num - 1].sessionId); @@ -148,6 +185,8 @@ export function App(): React.ReactElement { } } + // === SESSION MANAGEMENT SHORTCUTS === + // Ctrl+W to close current session if (key.ctrl && input === 'w') { if (activeSessionId) { @@ -172,20 +211,62 @@ export function App(): React.ReactElement { return; } - // Escape to go back to start screen + // Ctrl+R to toggle respawn on active session + if (key.ctrl && input === 'r') { + if (activeSessionId && activeSession?.mode === 'claude') { + toggleRespawn(); + } + return; + } + + // Escape to go back to start screen (doesn't close session) if (key.escape) { setViewMode('start'); return; } - // Forward other input to the active session - if (activeSessionId && !key.ctrl && !key.meta) { + // === FORWARD INPUT TO SESSION === + // Forward all other input to the active screen session + if (activeSessionId) { + // Handle special keys if (key.return) { sendInput(activeSessionId, '\r'); - } else if (key.backspace || key.delete) { + return; + } + if (key.backspace || key.delete) { sendInput(activeSessionId, '\x7f'); - } else if (input) { + return; + } + // Arrow keys - send ANSI escape sequences + if (key.upArrow) { + sendInput(activeSessionId, '\x1b[A'); + return; + } + if (key.downArrow) { + sendInput(activeSessionId, '\x1b[B'); + return; + } + if (key.rightArrow && !key.meta) { + sendInput(activeSessionId, '\x1b[C'); + return; + } + if (key.leftArrow && !key.meta) { + sendInput(activeSessionId, '\x1b[D'); + return; + } + // Ctrl+key combinations (send as control characters) + if (key.ctrl && input) { + // Convert to control character (Ctrl+A = 0x01, Ctrl+B = 0x02, etc.) + const code = input.toLowerCase().charCodeAt(0) - 96; + if (code >= 1 && code <= 26) { + sendInput(activeSessionId, String.fromCharCode(code)); + return; + } + } + // Regular character input + if (input && !key.ctrl && !key.meta) { sendInput(activeSessionId, input); + return; } } }); @@ -195,9 +276,24 @@ export function App(): React.ReactElement { setViewMode('main'); }, [selectSession]); - const handleCreateSession = useCallback(async () => { - const sessionId = await createSession(); - if (sessionId) { + const handleCreateSession = useCallback(async (caseName?: string, count?: number, mode: 'claude' | 'shell' = 'claude') => { + // Default to 'default' case if no case name provided (like web UI) + const sessionsToCreate = Math.min(Math.max(count || 1, 1), 20); + let lastSessionId: string | null = null; + + // Create sessions sequentially to avoid overwhelming the server + for (let i = 0; i < sessionsToCreate; i++) { + const sessionId = await createSession(caseName || 'default', mode); + if (sessionId) { + lastSessionId = sessionId; + } + // Small delay between session creations to allow server to process + if (i < sessionsToCreate - 1) { + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + + if (lastSessionId) { setViewMode('main'); } }, [createSession]); @@ -239,11 +335,14 @@ export function App(): React.ReactElement { return ( ); diff --git a/src/tui/components/HelpOverlay.tsx b/src/tui/components/HelpOverlay.tsx index 4d04848b..61a46eed 100644 --- a/src/tui/components/HelpOverlay.tsx +++ b/src/tui/components/HelpOverlay.tsx @@ -27,17 +27,30 @@ interface ShortcutGroup { const SHORTCUT_GROUPS: ShortcutGroup[] = [ { - title: 'Start Screen', + title: 'Start Screen - Sessions', shortcuts: [ - { key: '\u2191/\u2193', description: 'Navigate session list' }, + { key: '\u2191/\u2193', description: 'Navigate list' }, { key: 'Enter', description: 'View session in TUI' }, { key: 'a', description: 'Attach to screen (full terminal)' }, { key: 'd', description: 'Delete/kill selected session' }, - { key: 'n', description: 'Create new session' }, - { key: 'r', description: 'Refresh session list' }, + { key: 'c', description: 'Switch to cases view' }, + { key: 'n', description: 'Quick-start new session' }, + { key: 'r', description: 'Refresh list' }, { key: 'q', description: 'Quit' }, ], }, + { + title: 'Start Screen - Cases', + shortcuts: [ + { key: '\u2191/\u2193', description: 'Navigate list' }, + { key: 'Enter', description: 'Start Claude session with case' }, + { key: 'h', description: 'Start Shell session with case' }, + { key: 'm', description: 'Multi-start (1-20 sessions)' }, + { key: 'n', description: 'Create new case' }, + { key: 's', description: 'Switch to sessions view' }, + { key: 'r', description: 'Refresh list' }, + ], + }, { title: 'Main View - Navigation', shortcuts: [ @@ -53,6 +66,7 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [ { key: 'Ctrl+N', description: 'New session' }, { key: 'Ctrl+W', description: 'Close current session' }, { key: 'Ctrl+K', description: 'Kill all sessions' }, + { key: 'Ctrl+R', description: 'Toggle respawn (Claude sessions only)' }, ], }, { diff --git a/src/tui/components/StartScreen.tsx b/src/tui/components/StartScreen.tsx index c273471b..00d038cd 100644 --- a/src/tui/components/StartScreen.tsx +++ b/src/tui/components/StartScreen.tsx @@ -5,28 +5,40 @@ * * @description * The initial screen displayed when launching `claudeman tui`: - * - Reads session list from ~/.claudeman/screens.json - * - Shows session name, runtime, status (alive/dead), and mode + * - Shows existing sessions and available cases + * - Supports two modes: session list and case selection * - Arrow key navigation with visual selection highlight - * - Actions: Enter (view), a (attach), d (delete), n (new), r (refresh), q (quit) + * - Actions: Enter (view/select), a (attach), d (delete), n (new), c (cases), r (refresh), q (quit) * * This is the "home screen" users return to with Escape from the main view. */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; +import TextInput from 'ink-text-input'; import type { ScreenSession } from '../../types.js'; +interface CaseInfo { + name: string; + path: string; + hasClaudeMd: boolean; +} + interface StartScreenProps { sessions: ScreenSession[]; + cases: CaseInfo[]; onSelectSession: (session: ScreenSession) => void; onAttachSession: (session: ScreenSession) => void; onDeleteSession: (session: ScreenSession) => void; - onCreateSession: () => void; + onCreateSession: (caseName?: string, count?: number, mode?: 'claude' | 'shell') => void; + onCreateCase: (name: string) => Promise; onRefresh: () => void; + onRefreshCases: () => void; onExit: () => void; } +type ScreenMode = 'sessions' | 'cases' | 'new-case' | 'multi-start'; + /** * Formats a duration from milliseconds to a compact human-readable string. * @@ -55,86 +67,155 @@ function formatDuration(ms: number): string { * Start screen component for session discovery and selection. * * @description - * Renders a table of available sessions with: - * - Arrow key navigation (wraps at boundaries) - * - Visual selection highlight (blue background) - * - Status indicators (green=alive, red=dead) - * - Runtime and mode information - * - * **Keyboard Shortcuts:** - * - `↑/↓`: Navigate selection - * - `Enter`: View session in TUI - * - `a`: Attach directly to screen (exits TUI) - * - `d/x`: Delete/kill selected session + * Renders two views: + * - Sessions view: List of active sessions + * - Cases view: List of available cases to start a new session * * @param props - Component props - * @param props.sessions - Array of sessions to display - * @param props.onSelectSession - Callback to view session in TUI - * @param props.onAttachSession - Callback to attach directly to screen - * @param props.onDeleteSession - Callback to delete/kill session - * @param props.onCreateSession - Callback to create new session - * @param props.onRefresh - Callback to refresh session list - * @param props.onExit - Callback to exit TUI * @returns The start screen element */ export function StartScreen({ sessions, + cases, onSelectSession, onAttachSession, onDeleteSession, onCreateSession, + onCreateCase, onRefresh, + onRefreshCases, onExit, }: StartScreenProps): React.ReactElement { const now = Date.now(); + const [mode, setMode] = useState('sessions'); const [selectedIndex, setSelectedIndex] = useState(0); + const [newCaseName, setNewCaseName] = useState(''); + const [multiStartCount, setMultiStartCount] = useState('1'); + const [error, setError] = useState(null); - // Ensure selectedIndex is valid when sessions change - React.useEffect(() => { - if (selectedIndex >= sessions.length && sessions.length > 0) { - setSelectedIndex(sessions.length - 1); + // Reset selection when switching modes + useEffect(() => { + setSelectedIndex(0); + setError(null); + if (mode !== 'multi-start') { + setMultiStartCount('1'); } - }, [sessions.length, selectedIndex]); + }, [mode]); + + // Ensure selectedIndex is valid when list changes + useEffect(() => { + const maxIndex = mode === 'sessions' ? sessions.length - 1 : cases.length - 1; + if (selectedIndex > maxIndex && maxIndex >= 0) { + setSelectedIndex(maxIndex); + } + }, [sessions.length, cases.length, selectedIndex, mode]); // Handle keyboard input for navigation useInput((input, key) => { + // New case input mode - only handle escape and return + if (mode === 'new-case') { + if (key.escape) { + setMode('cases'); + setNewCaseName(''); + setError(null); + } + return; + } + + // Multi-start input mode - only handle escape and return + if (mode === 'multi-start') { + if (key.escape) { + setMode('cases'); + setMultiStartCount('1'); + setError(null); + } + return; + } + // Arrow key navigation - if (key.upArrow && sessions.length > 0) { - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : sessions.length - 1)); + const listLength = mode === 'sessions' ? sessions.length : cases.length; + if (key.upArrow && listLength > 0) { + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : listLength - 1)); return; } - if (key.downArrow && sessions.length > 0) { - setSelectedIndex((prev) => (prev < sessions.length - 1 ? prev + 1 : 0)); + if (key.downArrow && listLength > 0) { + setSelectedIndex((prev) => (prev < listLength - 1 ? prev + 1 : 0)); return; } - // Enter to view session in TUI - if (key.return && sessions.length > 0) { - onSelectSession(sessions[selectedIndex]); + // Enter to select + if (key.return && listLength > 0) { + if (mode === 'sessions') { + onSelectSession(sessions[selectedIndex]); + } else if (mode === 'cases') { + // Start session with selected case + onCreateSession(cases[selectedIndex].name); + } return; } - // 'a' to attach directly to screen - if (input === 'a' && sessions.length > 0 && sessions[selectedIndex].attached) { - onAttachSession(sessions[selectedIndex]); - return; + // Mode-specific shortcuts + if (mode === 'sessions') { + // 'a' to attach directly to screen + if (input === 'a' && sessions.length > 0 && sessions[selectedIndex].attached) { + onAttachSession(sessions[selectedIndex]); + return; + } + + // 'd' or 'x' to delete/kill session + if ((input === 'd' || input === 'x') && sessions.length > 0) { + onDeleteSession(sessions[selectedIndex]); + return; + } + + // 'c' to switch to cases view + if (input === 'c') { + onRefreshCases(); + setMode('cases'); + return; + } + + // 'n' to create new session (quick start with auto name) + if (input === 'n') { + onCreateSession(); + return; + } + } else if (mode === 'cases') { + // 's' to switch back to sessions view + if (input === 's') { + setMode('sessions'); + return; + } + + // 'n' to create new case + if (input === 'n') { + setMode('new-case'); + setNewCaseName(''); + return; + } + + // 'm' to start multiple sessions with selected case + if (input === 'm' && cases.length > 0) { + setMode('multi-start'); + setMultiStartCount('1'); + setError(null); + return; + } + + // 'h' to start a shell session with selected case + if (input === 'h' && cases.length > 0) { + onCreateSession(cases[selectedIndex].name, 1, 'shell'); + return; + } } - // 'd' or 'x' to delete/kill session - if ((input === 'd' || input === 'x') && sessions.length > 0) { - onDeleteSession(sessions[selectedIndex]); - return; - } - - // 'n' to create new session - if (input === 'n') { - onCreateSession(); - return; - } - - // 'r' to refresh session list + // 'r' to refresh if (input === 'r') { - onRefresh(); + if (mode === 'sessions') { + onRefresh(); + } else { + onRefreshCases(); + } return; } @@ -145,6 +226,168 @@ export function StartScreen({ } }); + // Handle new case name submission + const handleNewCaseSubmit = async (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + setError('Case name cannot be empty'); + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { + setError('Use only letters, numbers, hyphens, underscores'); + return; + } + + const success = await onCreateCase(trimmed); + if (success) { + setMode('cases'); + setNewCaseName(''); + setError(null); + } else { + setError('Failed to create case (may already exist)'); + } + }; + + // Handle multi-start submission + const handleMultiStartSubmit = (value: string) => { + const count = parseInt(value.trim(), 10); + if (isNaN(count) || count < 1) { + setError('Enter a number from 1 to 20'); + return; + } + if (count > 20) { + setError('Maximum 20 sessions at once'); + return; + } + + // Get the selected case name + const selectedCase = cases[selectedIndex]; + if (!selectedCase) { + setError('No case selected'); + return; + } + + // Start the sessions + onCreateSession(selectedCase.name, count); + setMode('cases'); + setMultiStartCount('1'); + setError(null); + }; + + // Get currently selected item for display + const getSelectedItem = (): { type: 'session' | 'case'; name: string; path?: string } | null => { + if (mode === 'sessions' && sessions.length > 0 && selectedIndex < sessions.length) { + const session = sessions[selectedIndex]; + return { type: 'session', name: session.name || 'unnamed', path: session.workingDir }; + } + if ((mode === 'cases' || mode === 'multi-start') && cases.length > 0 && selectedIndex < cases.length) { + const caseInfo = cases[selectedIndex]; + return { type: 'case', name: caseInfo.name, path: caseInfo.path }; + } + return null; + }; + + const selectedItem = getSelectedItem(); + + // Render new case input mode + if (mode === 'new-case') { + return ( + + + + Create New Case + + + + + Enter case name (letters, numbers, hyphens, underscores): + + > + + + {error && ( + + {error} + + )} + + + + + [Enter] + Create + [Esc] + Cancel + + + + ); + } + + // Render multi-start input mode + if (mode === 'multi-start') { + const selectedCase = cases[selectedIndex]; + return ( + + + + Start Multiple Sessions + + + + {/* Show selected case */} + + + Selected Case: + {selectedCase?.name || 'none'} + + + + + How many sessions to start? (1-20): + + > + + + {error && ( + + {error} + + )} + + + + + [Enter] + Start Sessions + [Esc] + Cancel + + + + ); + } + return ( {/* Header */} @@ -160,88 +403,193 @@ export function StartScreen({ + {/* Mode tabs */} - Session Manager - Press ? for help + + + {' [s] Sessions '} + + + + {' [c] Cases '} + + | Press ? for help + - {/* Session list */} - {sessions.length === 0 ? ( - - No sessions found - Press [n] to create a new session - - ) : ( - - {/* Table header */} - - - {' '} - {'NAME'.padEnd(22)} - {'RUNTIME'.padEnd(12)} - {'STATUS'.padEnd(10)} - {'MODE'.padEnd(10)} - - - - {/* Session rows */} - {sessions.map((session, index) => { - const runtime = formatDuration(now - session.createdAt); - const statusColor = session.attached ? 'green' : 'red'; - const statusIcon = session.attached ? '\u25CF' : '\u25CB'; - const statusText = session.attached ? 'alive' : 'dead'; - const name = (session.name || 'unnamed').slice(0, 20); - const isSelected = index === selectedIndex; - - return ( - - {isSelected ? ( - - {' \u25B6 '} - {name.padEnd(22)} - {runtime.padEnd(12)} - - {statusIcon} {statusText.padEnd(8)} - - {session.mode.padEnd(10)} - - ) : ( - - {' '} - {name.padEnd(22)} - {runtime.padEnd(12)} - - {statusIcon} {statusText.padEnd(8)} - - {session.mode.padEnd(10)} - - )} - - ); - })} + {/* Selected item display */} + {selectedItem && ( + + + Selected: + {selectedItem.name} + {selectedItem.path && ( + ({selectedItem.path.replace(process.env.HOME || '', '~')}) + )} + )} - {/* Footer with controls */} - - - - [n] - New - [{'\u2191\u2193'}] - Navigate - [Enter] - View - [a] - Attach - [d] - Delete - [r] - Refresh - [q] - Quit - - - + {/* Sessions view */} + {mode === 'sessions' && ( + <> + {sessions.length === 0 ? ( + + No sessions found + Press [n] to create a new session, or [c] to select a case + + ) : ( + + {/* Table header */} + + + {' '} + {'NAME'.padEnd(22)} + {'RUNTIME'.padEnd(12)} + {'STATUS'.padEnd(10)} + {'MODE'.padEnd(10)} + + + + {/* Session rows */} + {sessions.map((session, index) => { + const runtime = formatDuration(now - session.createdAt); + const statusColor = session.attached ? 'green' : 'red'; + const statusIcon = session.attached ? '\u25CF' : '\u25CB'; + const statusText = session.attached ? 'alive' : 'dead'; + const name = (session.name || 'unnamed').slice(0, 20); + const isSelected = index === selectedIndex; + + return ( + + {isSelected ? ( + + {' \u25B6 '} + {name.padEnd(22)} + {runtime.padEnd(12)} + + {statusIcon} {statusText.padEnd(8)} + + {session.mode.padEnd(10)} + + ) : ( + + {' '} + {name.padEnd(22)} + {runtime.padEnd(12)} + + {statusIcon} {statusText.padEnd(8)} + + {session.mode.padEnd(10)} + + )} + + ); + })} + + )} + + {/* Sessions footer */} + + + + [n] + New + [c] + Cases + [{'\u2191\u2193'}] + Navigate + [Enter] + View + [a] + Attach + [d] + Delete + [q] + Quit + + + + + )} + + {/* Cases view */} + {mode === 'cases' && ( + <> + {cases.length === 0 ? ( + + No cases found + Press [n] to create a new case + + ) : ( + + {/* Table header */} + + + {' '} + {'CASE NAME'.padEnd(30)} + {'PATH'.padEnd(40)} + + + + {/* Case rows */} + {cases.map((caseInfo, index) => { + const isSelected = index === selectedIndex; + const name = caseInfo.name.slice(0, 28); + const path = caseInfo.path.replace(process.env.HOME || '', '~').slice(0, 38); + + return ( + + {isSelected ? ( + + {' \u25B6 '} + {name.padEnd(30)} + {path.padEnd(40)} + + ) : ( + + {' '} + {name.padEnd(30)} + {path.padEnd(40)} + + )} + + ); + })} + + )} + + {/* Cases footer */} + + + + [Enter] + Claude + [h] + Shell + [m] + Multi (1-20) + [n] + New Case + [s] + Sessions + [r] + Refresh + [q] + Quit + + + + + )} ); } diff --git a/src/tui/hooks/useSessionManager.ts b/src/tui/hooks/useSessionManager.ts index 5efabeb0..2f6e8c6a 100644 --- a/src/tui/hooks/useSessionManager.ts +++ b/src/tui/hooks/useSessionManager.ts @@ -34,12 +34,112 @@ const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json'); const INNER_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json'); const OUTPUT_POLL_INTERVAL = 500; // Poll terminal output every 500ms +/** + * Emoji to ASCII replacement map for screen hardcopy output. + * GNU screen's hardcopy doesn't handle UTF-8 multi-byte characters well, + * so we replace common Claude Code emoji with ASCII equivalents. + */ +const EMOJI_REPLACEMENTS: [RegExp, string][] = [ + // Claude Code logo/branding + [/\u{1F9E0}/gu, '*'], // 🧠 brain -> * + [/\u{2728}/gu, '*'], // ✨ sparkles -> * + [/\u{1F4AC}/gu, '>'], // 💬 speech bubble -> > + [/\u{1F916}/gu, '[bot]'], // 🤖 robot -> [bot] + + // Status indicators + [/\u{2714}/gu, '[ok]'], // ✔ check mark -> [ok] + [/\u{2705}/gu, '[ok]'], // ✅ check mark button -> [ok] + [/\u{274C}/gu, '[x]'], // ❌ cross mark -> [x] + [/\u{26A0}/gu, '[!]'], // ⚠ warning -> [!] + [/\u{2139}/gu, '[i]'], // ℹ info -> [i] + [/\u{1F6A8}/gu, '[!]'], // 🚨 rotating light -> [!] + + // Progress/activity + [/\u{23F3}/gu, '...'], // ⏳ hourglass -> ... + [/\u{231B}/gu, '...'], // ⌛ hourglass done -> ... + [/\u{1F504}/gu, '(...)'], // 🔄 refresh -> (...) + [/\u{25B6}/gu, '>'], // ▶ play -> > + [/\u{23F8}/gu, '||'], // ⏸ pause -> || + [/\u{23F9}/gu, '[]'], // ⏹ stop -> [] + + // File/folder icons + [/\u{1F4C1}/gu, '[dir]'], // 📁 folder -> [dir] + [/\u{1F4C2}/gu, '[dir]'], // 📂 open folder -> [dir] + [/\u{1F4C4}/gu, '[file]'], // 📄 file -> [file] + [/\u{1F4DD}/gu, '[edit]'], // 📝 memo -> [edit] + + // Arrows and navigation + [/\u{2190}/gu, '<-'], // ← left arrow + [/\u{2192}/gu, '->'], // → right arrow + [/\u{2191}/gu, '^'], // ↑ up arrow + [/\u{2193}/gu, 'v'], // ↓ down arrow + [/\u{21B5}/gu, ''], // ↵ return symbol -> + [/\u{23CE}/gu, ''], // ⏎ return symbol -> + + // Special Unicode box-drawing and symbols that may corrupt + [/\u{25B8}/gu, '>'], // ▸ small right triangle + [/\u{25B9}/gu, '>'], // ▹ white small right triangle + [/\u{2022}/gu, '-'], // • bullet -> - + [/\u{25CF}/gu, 'o'], // ● black circle -> o + [/\u{25CB}/gu, 'o'], // ○ white circle -> o + [/\u{25A0}/gu, '#'], // ■ black square -> # + [/\u{25A1}/gu, '[]'], // □ white square -> [] + [/\u{2261}/gu, '='], // ≡ hamburger menu -> = + + // Misc + [/\u{1F512}/gu, '[lock]'], // 🔒 lock + [/\u{1F513}/gu, '[unlock]'], // 🔓 unlock + [/\u{1F527}/gu, '[tool]'], // 🔧 wrench + [/\u{2699}/gu, '[gear]'], // ⚙ gear + [/\u{1F50D}/gu, '[search]'], // 🔍 magnifying glass + [/\u{1F4E6}/gu, '[pkg]'], // 📦 package + [/\u{1F680}/gu, '[>]'], // 🚀 rocket + [/\u{1F3AF}/gu, '[*]'], // 🎯 target + + // Catch-all for any remaining emoji in common ranges + // These will appear as replacement characters otherwise + [/[\u{1F300}-\u{1F9FF}]/gu, ''], // Misc symbols and pictographs + [/[\u{2600}-\u{26FF}]/gu, ''], // Misc symbols + [/[\u{FE00}-\u{FE0F}]/gu, ''], // Variation selectors +]; + +/** + * Sanitizes screen hardcopy output by replacing emoji with ASCII equivalents. + * + * @param content - Raw hardcopy output that may contain corrupted UTF-8 + * @returns Sanitized string with emoji replaced by ASCII + */ +function sanitizeHardcopyOutput(content: string): string { + let result = content; + + // Apply all emoji replacements + for (const [pattern, replacement] of EMOJI_REPLACEMENTS) { + result = result.replace(pattern, replacement); + } + + // Remove any remaining replacement characters (U+FFFD) that indicate + // encoding issues with multi-byte sequences + result = result.replace(/\uFFFD+/g, ''); + + // Remove any other non-printable characters except common whitespace + // This catches any remaining problematic bytes + result = result.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); + + return result; +} + interface RespawnStatus { enabled: boolean; state: string; cycleCount: number; } +interface CaseInfo { + name: string; + path: string; + hasClaudeMd: boolean; +} + interface SessionManagerState { sessions: ScreenSession[]; activeSessionId: string | null; @@ -48,14 +148,19 @@ interface SessionManagerState { innerLoopState: InnerLoopState | null; innerTodos: InnerTodoItem[]; respawnStatus: RespawnStatus | null; + cases: CaseInfo[]; refreshSessions: () => void; + refreshCases: () => Promise; selectSession: (sessionId: string) => void; - createSession: () => Promise; + createSession: (caseName?: string, mode?: 'claude' | 'shell') => Promise; + createCase: (name: string) => Promise; killSession: (sessionId: string) => void; killAllSessions: () => void; nextSession: () => void; prevSession: () => void; sendInput: (sessionId: string, input: string) => void; + toggleRespawn: () => Promise; + renameSession: (sessionId: string, name: string) => Promise; } /** @@ -160,6 +265,7 @@ export function useSessionManager(): SessionManagerState { const [innerLoopState, setInnerLoopState] = useState(null); const [innerTodos, setInnerTodos] = useState([]); const [respawnStatus, setRespawnStatus] = useState(null); + const [cases, setCases] = useState([]); const outputBufferRef = useRef(''); // Load sessions on mount and watch for changes @@ -271,12 +377,20 @@ export function useSessionManager(): SessionManagerState { try { const hardcopyFile = `/tmp/claudeman-${activeSessionId}-hardcopy`; - execSync(`screen -S ${activeSession.screenName} -X hardcopy ${hardcopyFile}`, { + // Use screen with UTF-8 mode (-U) for proper character handling + execSync(`screen -U -S ${activeSession.screenName} -X hardcopy ${hardcopyFile}`, { encoding: 'utf-8', timeout: 1000, + env: { + ...process.env, + LANG: process.env.LANG || 'en_US.UTF-8', + LC_ALL: process.env.LC_ALL || 'en_US.UTF-8', + } }); if (existsSync(hardcopyFile)) { - const content = readFileSync(hardcopyFile, 'utf-8'); + const rawContent = readFileSync(hardcopyFile, 'utf-8'); + // Sanitize emoji/unicode that screen hardcopy corrupts + const content = sanitizeHardcopyOutput(rawContent); // Only update if content changed if (content !== outputBufferRef.current) { outputBufferRef.current = content; @@ -310,6 +424,25 @@ export function useSessionManager(): SessionManagerState { setSessions(loadSessions()); }, []); + // Refresh cases from API + const refreshCases = useCallback(async () => { + try { + const response = await fetch('http://localhost:3000/api/cases'); + if (response.ok) { + const data = await response.json() as CaseInfo[]; + setCases(data); + } + } catch { + // Server not running, clear cases + setCases([]); + } + }, []); + + // Load cases on mount + useEffect(() => { + refreshCases(); + }, [refreshCases]); + // Select a session const selectSession = useCallback((sessionId: string) => { setActiveSessionId(sessionId); @@ -318,23 +451,46 @@ export function useSessionManager(): SessionManagerState { // Polling effect will handle fetching output }, []); - // Create new session - const createSession = useCallback(async (): Promise => { + // Create a new case + const createCase = useCallback(async (name: string): Promise => { + try { + const response = await fetch('http://localhost:3000/api/cases', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + + if (response.ok) { + const data = await response.json() as { success?: boolean }; + if (data.success) { + await refreshCases(); + return true; + } + } + return false; + } catch { + return false; + } + }, [refreshCases]); + + // Create new session (optionally with case name and mode) + const createSession = useCallback(async (caseName?: string, mode: 'claude' | 'shell' = 'claude'): Promise => { try { // Use the web API to create a session if server is running const response = await fetch('http://localhost:3000/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName: `tui-${Date.now()}` }), + body: JSON.stringify({ caseName: caseName || `case-${Date.now()}`, mode }), }); if (response.ok) { - const data = await response.json() as { success?: boolean; sessionId?: string }; + const data = await response.json() as { success?: boolean; sessionId?: string; caseName?: string }; if (data.success && data.sessionId) { // Refresh and select new session const sessionId = data.sessionId; setTimeout(() => { refreshSessions(); + refreshCases(); setActiveSessionId(sessionId); }, 500); return sessionId; @@ -355,7 +511,7 @@ export function useSessionManager(): SessionManagerState { ); return null; } - }, [refreshSessions]); + }, [refreshSessions, refreshCases]); // Kill a session const killSession = useCallback((sessionId: string) => { @@ -437,6 +593,55 @@ export function useSessionManager(): SessionManagerState { } }, [sessions]); + // Toggle respawn for active session + const toggleRespawn = useCallback(async (): Promise => { + if (!activeSessionId) return false; + + try { + const isEnabled = respawnStatus?.enabled || false; + const endpoint = isEnabled + ? `http://localhost:3000/api/sessions/${activeSessionId}/respawn/stop` + : `http://localhost:3000/api/sessions/${activeSessionId}/respawn/enable`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(isEnabled ? {} : { config: {} }), + }); + + if (response.ok) { + const data = await response.json() as { success?: boolean }; + return data.success || false; + } + return false; + } catch { + return false; + } + }, [activeSessionId, respawnStatus]); + + // Rename a session + const renameSession = useCallback(async (sessionId: string, name: string): Promise => { + try { + const response = await fetch(`http://localhost:3000/api/sessions/${sessionId}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + + if (response.ok) { + const data = await response.json() as { success?: boolean }; + if (data.success) { + // Refresh sessions to get updated name + setTimeout(refreshSessions, 100); + return true; + } + } + return false; + } catch { + return false; + } + }, [refreshSessions]); + return { sessions, activeSessionId, @@ -445,13 +650,18 @@ export function useSessionManager(): SessionManagerState { innerLoopState, innerTodos, respawnStatus, + cases, refreshSessions, + refreshCases, selectSession, createSession, + createCase, killSession, killAllSessions, nextSession, prevSession, sendInput, + toggleRespawn, + renameSession, }; } diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 93062223..03188a8a 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -11,6 +11,7 @@ * - Real-time terminal output via screen hardcopy polling * - Ralph Wiggum loop tracking * - Respawn status monitoring + * - Auto-detection and startup of web server if not running * * @example * ```bash @@ -25,6 +26,8 @@ */ import { render } from 'ink'; +import { spawn } from 'child_process'; +import { createInterface } from 'readline'; import { App } from './App.js'; /** @@ -43,6 +46,88 @@ function isRawModeSupported(): boolean { ); } +/** + * Checks if the Claudeman web server is running. + * + * @param port - Port to check (default 3000) + * @returns Promise that resolves to true if server is running + */ +async function isWebServerRunning(port: number = 3000): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2000); + + const response = await fetch(`http://localhost:${port}/api/status`, { + signal: controller.signal, + }); + + clearTimeout(timeout); + return response.ok; + } catch { + return false; + } +} + +/** + * Prompts the user with a yes/no question. + * + * @param question - The question to ask + * @returns Promise that resolves to true for yes, false for no + */ +async function promptYesNo(question: string): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(`${question} [Y/n] `, (answer) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + resolve(normalized === '' || normalized === 'y' || normalized === 'yes'); + }); + }); +} + +/** + * Starts the Claudeman web server in the background. + * + * @param port - Port to run on (default 3000) + * @returns true if server started successfully + */ +function startWebServerInBackground(port: number = 3000): boolean { + try { + // Find the current script's directory to locate the web server entry + const child = spawn('node', [ + '--import', 'tsx/esm', + `${process.cwd()}/src/index.ts`, + 'web', + '-p', String(port), + ], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + + child.unref(); + return true; + } catch { + return false; + } +} + +/** + * TUI startup options. + */ +interface TUIOptions { + /** Auto-start web server without prompting if not running */ + autoStartWeb?: boolean; + /** Skip web server check entirely */ + skipWebCheck?: boolean; + /** Web server port */ + port?: number; +} + /** * Starts the TUI application in the current terminal. * @@ -51,10 +136,16 @@ function isRawModeSupported(): boolean { * The terminal is cleared for a full-screen experience. * This function blocks until the user exits the TUI. * + * Before starting, checks if the web server is running and offers to start it + * in the background if not (unless skipWebCheck is true). + * + * @param options - TUI startup options * @throws Exits with code 1 if TTY/raw mode is not supported * @returns Promise that resolves when the TUI exits */ -export async function startTUI(): Promise { +export async function startTUI(options: TUIOptions = {}): Promise { + const { autoStartWeb = false, skipWebCheck = false, port = 3000 } = options; + // Check if we're in an interactive terminal if (!isRawModeSupported()) { console.error('Error: TUI requires an interactive terminal with TTY support.'); @@ -62,6 +153,50 @@ export async function startTUI(): Promise { process.exit(1); } + // Check if web server is running (unless skipped) + if (!skipWebCheck) { + const serverRunning = await isWebServerRunning(port); + + if (!serverRunning) { + console.log('\x1b[33mClaudeman web server is not running.\x1b[0m'); + console.log('The TUI requires the web server to create and manage sessions.\n'); + + let startServer = autoStartWeb; + + if (!autoStartWeb) { + startServer = await promptYesNo('Would you like to start the web server in the background?'); + } + + if (startServer) { + console.log(`\x1b[32mStarting web server on port ${port}...\x1b[0m`); + const success = startWebServerInBackground(port); + + if (success) { + // Wait a moment for the server to start + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Verify it started + const nowRunning = await isWebServerRunning(port); + if (nowRunning) { + console.log('\x1b[32mWeb server started successfully!\x1b[0m\n'); + } else { + console.log('\x1b[33mServer may still be starting... continuing with TUI.\x1b[0m'); + console.log('If you have issues, try running "claudeman web" in a separate terminal.\n'); + } + } else { + console.log('\x1b[31mFailed to start web server.\x1b[0m'); + console.log('Please run "claudeman web" in a separate terminal first.\n'); + } + } else { + console.log('\nYou can start the web server anytime with: claudeman web'); + console.log('Some TUI features may not work without the web server.\n'); + } + + // Brief pause before clearing screen + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + // Clear the terminal for full-screen experience process.stdout.write('\x1b[2J\x1b[H'); diff --git a/src/types.ts b/src/types.ts index 3d1e0fe6..a32a81bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -299,6 +299,8 @@ export interface CreateCaseRequest { export interface QuickStartRequest { /** Optional case name, defaults to 'testcase' */ caseName?: string; + /** Session mode: 'claude' for Claude CLI, 'shell' for bash shell */ + mode?: 'claude' | 'shell'; } /** diff --git a/src/web/server.ts b/src/web/server.ts index b61c70a2..dc293ce2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -12,7 +12,7 @@ import Fastify, { FastifyInstance, FastifyReply } from 'fastify'; import fastifyStatic from '@fastify/static'; -import { join, dirname } from 'node:path'; +import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; @@ -749,6 +749,13 @@ export class WebServer extends EventEmitter { const casePath = join(casesDir, name); + // Security: Path traversal protection - ensure resolved path is within casesDir + const resolvedPath = resolve(casePath); + const resolvedBase = resolve(casesDir); + if (!resolvedPath.startsWith(resolvedBase + '/') && resolvedPath !== resolvedBase) { + return { success: false, error: 'Invalid case path' }; + } + if (existsSync(casePath)) { return { success: false, error: 'Case already exists' }; } @@ -792,7 +799,7 @@ export class WebServer extends EventEmitter { return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` }; } - const { caseName = 'testcase' } = req.body as QuickStartRequest; + const { caseName = 'testcase', mode = 'claude' } = req.body as QuickStartRequest; // Validate case name if (!/^[a-zA-Z0-9_-]+$/.test(caseName)) { @@ -801,6 +808,13 @@ export class WebServer extends EventEmitter { const casePath = join(casesDir, caseName); + // Security: Path traversal protection - ensure resolved path is within casesDir + const resolvedPath = resolve(casePath); + const resolvedBase = resolve(casesDir); + if (!resolvedPath.startsWith(resolvedBase + '/') && resolvedPath !== resolvedBase) { + return { success: false, error: 'Invalid case path' }; + } + // Create case folder and CLAUDE.md if it doesn't exist if (!existsSync(casePath)) { try { @@ -822,16 +836,22 @@ export class WebServer extends EventEmitter { const session = new Session({ workingDir: casePath, screenManager: this.screenManager, - useScreen: true + useScreen: true, + mode: mode, }); this.sessions.set(session.id, session); this.setupSessionListeners(session); this.broadcast('session:created', session.toDetailedState()); - // Start interactive mode + // Start in the appropriate mode try { - await session.startInteractive(); - this.broadcast('session:interactive', { id: session.id }); + if (mode === 'shell') { + await session.startShell(); + this.broadcast('session:interactive', { id: session.id, mode: 'shell' }); + } else { + await session.startInteractive(); + this.broadcast('session:interactive', { id: session.id }); + } this.broadcast('session:updated', { session: session.toDetailedState() }); return { @@ -1034,10 +1054,11 @@ export class WebServer extends EventEmitter { this.broadcast('session:exit', { id: session.id, code }); this.broadcast('session:updated', session.toDetailedState()); - // Clean up respawn controller when session exits + // Clean up respawn controller when session exits (stop + remove listeners) const controller = this.respawnControllers.get(session.id); if (controller) { controller.stop(); + controller.removeAllListeners(); this.respawnControllers.delete(session.id); } }); @@ -1285,11 +1306,10 @@ export class WebServer extends EventEmitter { run.status = 'stopped'; run.logs.push(`[${new Date().toISOString()}] Run stopped by user`); - if (run.sessionId) { - const session = this.sessions.get(run.sessionId); - if (session) { - await session.stop(); - } + // Use cleanupSession for proper resource cleanup (listeners, respawn, etc.) + if (run.sessionId && this.sessions.has(run.sessionId)) { + await this.cleanupSession(run.sessionId); + run.sessionId = null; } this.broadcast('scheduled:stopped', run); @@ -1570,22 +1590,25 @@ export class WebServer extends EventEmitter { // Stop screen stats collection this.screenManager.stopStatsCollection(); - // Stop all respawn controllers + // Stop all respawn controllers and remove listeners for (const controller of this.respawnControllers.values()) { controller.stop(); + controller.removeAllListeners(); } this.respawnControllers.clear(); - // Stop all sessions - for (const session of this.sessions.values()) { - await session.stop(); - } - - // Stop all scheduled runs + // Stop all scheduled runs first (they have their own session cleanup) for (const [id] of this.scheduledRuns) { await this.stopScheduledRun(id); } + // Properly clean up all remaining sessions (removes listeners, clears state, etc.) + // Don't kill screens on server stop - they can be reattached on restart + const sessionIds = Array.from(this.sessions.keys()); + for (const sessionId of sessionIds) { + await this.cleanupSession(sessionId, false); + } + await this.app.close(); } } diff --git a/test/session-cleanup.test.ts b/test/session-cleanup.test.ts index 885e9559..48696e9f 100644 --- a/test/session-cleanup.test.ts +++ b/test/session-cleanup.test.ts @@ -101,7 +101,7 @@ describe('Session Cleanup', () => { for (const id of sessionIds) { expect(sessions.find((s: any) => s.id === id)).toBeUndefined(); } - }); + }, 60000); // Extended timeout for multi-session test }); describe('Respawn Controller Cleanup', () => { diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..6d689792 --- /dev/null +++ b/todo.md @@ -0,0 +1,34 @@ +# Claudeman TODO List + +## Completed + +- [x] Memory leaks intensive check - Fixed timer tracking in session.ts, proper cleanup in server.ts +- [x] Security hardening - Added input validation, command injection prevention, path traversal protection +- [x] Performance optimization - Pre-compiled regex patterns, event debouncing, buffer management +- [x] Reliability improvements - Proper cleanup on session exit, listener cleanup +- [x] TUI parity with web interface: + - [x] Shell mode support (h key in cases view) + - [x] Multi-start (m key, 1-20 sessions at once) + - [x] Respawn toggle (Ctrl+R) + - [x] Session rename API support + - [x] Selected item display in start screen + - [x] Help overlay with all shortcuts + - [x] Web server auto-detection and startup + - Prompts to start web server if not running + - `--with-web` flag for automatic startup (no prompt) + - `--no-web` flag to skip check entirely +- [x] Documentation updates - Updated CLAUDE.md with TUI shortcuts + +## Ongoing (Long-term stability) + +- [ ] Monitor for edge cases during extended runtime +- [ ] Consider implementing graceful degradation for API failures +- [ ] Add metrics/logging for long-running session health + +## Future Enhancements + +- [ ] TUI: Add inline session rename UI (currently API only) +- [ ] TUI: Add task panel for background tasks +- [ ] TUI: Add scheduled runs display +- [ ] TUI: Add auto-compact/auto-clear configuration UI +- [ ] Consider WebSocket for TUI instead of polling (reduce latency)