From 88422cab57acc0a14b81de7f11744263918bfd6a Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 18 Feb 2026 12:51:35 +0100 Subject: [PATCH] chore: bump version to 0.1538 --- .protected-screens | 9 - CLAUDE.md | 11 +- README.md | 10 +- agent-teams/README.md | 2 +- docs/respawn-state-machine.md | 2 +- install.sh | 130 +--- package.json | 2 +- scripts/screen-chooser.sh | 719 --------------------- scripts/screen-manager.sh | 494 --------------- scripts/tmux-chooser.sh | 4 - scripts/tmux-manager.sh | 4 - src/ai-checker-base.ts | 65 +- src/ai-idle-checker.ts | 12 +- src/ai-plan-checker.ts | 14 +- src/mux-factory.ts | 53 +- src/mux-interface.ts | 25 +- src/plan-orchestrator.ts | 8 +- src/respawn-controller.ts | 10 +- src/screen-manager.ts | 1004 ------------------------------ src/session.ts | 89 ++- src/templates/case-template.md | 6 +- src/tmux-manager.ts | 54 +- src/types.ts | 42 +- src/utils/claude-cli-resolver.ts | 4 +- src/utils/nice-wrapper.ts | 3 +- src/web/public/app.js | 126 ++-- src/web/public/index.html | 10 +- src/web/public/styles.css | 2 +- src/web/schemas.ts | 6 +- src/web/server.ts | 150 ++--- test/ai-idle-checker.test.ts | 22 +- test/mux-factory.test.ts | 118 +--- test/ralph-integration.test.ts | 14 +- test/respawn-controller.test.ts | 2 +- test/respawn-test-utils.ts | 14 +- test/screen-manager.test.ts | 616 ------------------ test/session-state.test.ts | 34 +- test/setup.ts | 4 +- test/templates.test.ts | 2 +- test/tmux-manager.test.ts | 20 +- 40 files changed, 369 insertions(+), 3547 deletions(-) delete mode 100644 .protected-screens delete mode 100755 scripts/screen-chooser.sh delete mode 100755 scripts/screen-manager.sh delete mode 100644 src/screen-manager.ts delete mode 100644 test/screen-manager.test.ts diff --git a/.protected-screens b/.protected-screens deleted file mode 100644 index 8acad214..00000000 --- a/.protected-screens +++ /dev/null @@ -1,9 +0,0 @@ -# PROTECTED SCREEN SESSIONS - DO NOT KILL -# Captured at 2026-02-12 before agent-teams implementation -# These are live Claudeman sessions running on port 3000 -685273.claudeman-a5929405 -684663.claudeman-07a604fb -684529.claudeman-a55c7db2 -# User-specified protected sessions (NEVER kill these): -# w1-claudeman, w2, w3-claudeman -# NEVER run tests on port 3000 (live dev server) diff --git a/CLAUDE.md b/CLAUDE.md index b0c9e8c0..338e26db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,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.1537 (must match `package.json` for npm publish) +**Version**: 0.1538 (must match `package.json` for npm publish) ## Project Overview @@ -78,7 +78,7 @@ journalctl --user -u claudeman-web -f ## Common Gotchas - **`npm run dev` is NOT the web server** — it shows CLI help. Use `npx tsx src/index.ts web` -- **Single-line prompts only** — `writeViaScreen()` sends text and Enter separately; multi-line breaks Ink +- **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink - **Don't kill tmux sessions blindly** — Check `$CLAUDEMAN_TMUX` first; you might be inside one - **Never run full test suite** — `npx vitest run` spawns/kills tmux sessions and will crash your Claudeman session. Run individual test files only. @@ -98,7 +98,6 @@ journalctl --user -u claudeman-web -f | `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type | | `src/mux-factory.ts` | Create tmux multiplexer (`CLAUDEMAN_MUX` override for legacy screen) | | `src/tmux-manager.ts` | tmux session management | -| `src/screen-manager.ts` | GNU screen fallback (deprecated) | | `src/session-manager.ts` | Session lifecycle, cleanup | | `src/state-store.ts` | State persistence to `~/.claudeman/state.json` | | `src/respawn-controller.ts` | State machine for autonomous cycling | @@ -163,9 +162,9 @@ journalctl --user -u claudeman-web -f ### Key Patterns -**Input to sessions**: Use `session.writeViaScreen()` for programmatic input (respawn, auto-compact). Uses tmux `send-keys -l` (literal text) + `send-keys Enter`. All prompts must be single-line. +**Input to sessions**: Use `session.writeViaMux()` for programmatic input (respawn, auto-compact). Uses tmux `send-keys -l` (literal text) + `send-keys Enter`. All prompts must be single-line. -**Terminal multiplexer**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts the backend. `createMultiplexer()` from `src/mux-factory.ts` creates the tmux backend. Legacy screen fallback exists via `CLAUDEMAN_MUX=screen` but is deprecated. +**Terminal multiplexer**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts the backend. `createMultiplexer()` from `src/mux-factory.ts` creates the tmux backend. **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. @@ -337,8 +336,6 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base | `scripts/ralph-wizard-prod.mjs` | Production Ralph wizard with HTTPS support | | `scripts/browser-comparison.mjs` | Compare Playwright, Puppeteer, and Agent-Browser frameworks | | `scripts/ralph-wizard-demo.mjs` | Demo Ralph Loop wizard via visible browser | -| `scripts/screen-chooser.sh` | Mobile-friendly Screen session picker (`sc` alias) | -| `scripts/screen-manager.sh` | Interactive GNU Screen session manager (deprecated) | | `scripts/test-links-browser.mjs` | Browser test for clickable terminal file links | | `scripts/test-patterns.mjs` | Test file path link detection regex patterns | | `scripts/watch-subagents.ts` | Real-time subagent transcript watcher (list, follow by session/agent ID) | diff --git a/README.md b/README.md index 2a0b4e8b..be8a0f2e 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,13 @@ Real-time desktop notifications when sessions need attention — never miss a pe ### 💾 Persistent Sessions -Every Claude session runs inside **tmux** (or GNU Screen as fallback) — sessions survive server restarts, network drops, and machine sleep. +Every Claude session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. ```bash # Your sessions are always recoverable -CLAUDEMAN_SCREEN=1 +CLAUDEMAN_MUX=1 CLAUDEMAN_SESSION_ID=abc-123-def -CLAUDEMAN_SCREEN_NAME=claudeman-myproject +CLAUDEMAN_MUX_NAME=claudeman-myproject ``` - Sessions auto-recover on startup (dual redundancy: `state.json` + `mux-sessions.json`) @@ -322,7 +322,7 @@ npm install -g claudeman - Node.js 18+ - [Claude CLI](https://docs.anthropic.com/en/docs/claude-code/getting-started) installed -- tmux (`apt install tmux` / `brew install tmux`) — preferred; GNU Screen as fallback (`CLAUDEMAN_MUX=screen`) +- tmux (`apt install tmux` / `brew install tmux`) ## Getting Started @@ -517,7 +517,7 @@ flowchart TB end subgraph Persistence["Persistence Layer"] - SCR["Mux Manager
(tmux/screen)"] + SCR["Mux Manager
(tmux)"] SS["State Store
state.json"] end diff --git a/agent-teams/README.md b/agent-teams/README.md index 3d775128..28c00c0b 100644 --- a/agent-teams/README.md +++ b/agent-teams/README.md @@ -138,7 +138,7 @@ This means: | **tmux** | `--teammate-mode tmux` | Split panes | tmux installed | | **iTerm2** | Auto-detected | Native split panes | iTerm2 + `it2` CLI | -**For Claudeman: use `in-process` only.** Claudeman manages its own tmux/screen sessions externally. +**For Claudeman: use `in-process` only.** Claudeman manages its own tmux sessions externally. **In-process UI elements:** - Status bar: `@main @teammate1 @teammate2 ...` with `shift+↑ to expand` diff --git a/docs/respawn-state-machine.md b/docs/respawn-state-machine.md index 411d587a..07dbc06b 100644 --- a/docs/respawn-state-machine.md +++ b/docs/respawn-state-machine.md @@ -46,7 +46,7 @@ After sending each step (update, clear, init, kickstart), the controller waits f ## Idle Detection (Multi-Layer) 1. **Completion message**: Primary signal - detects "Worked for Xm Xs" time patterns (requires "Worked" prefix to avoid false positives) -2. **AI Idle Check** (enabled by default): Spawns a fresh Claude session in a screen to analyze terminal output and provide IDLE/WORKING verdict. Uses `claude-opus-4-5-20251101` by default, sends last 16k chars of terminal buffer. Timeout 90s, cooldown 3min after WORKING. Auto-disables after 3 consecutive errors. The AI prompt is conservative: when in doubt, it answers WORKING. +2. **AI Idle Check** (enabled by default): Spawns a fresh Claude session in a tmux session to analyze terminal output and provide IDLE/WORKING verdict. Uses `claude-opus-4-5-20251101` by default, sends last 16k chars of terminal buffer. Timeout 90s, cooldown 3min after WORKING. Auto-disables after 3 consecutive errors. The AI prompt is conservative: when in doubt, it answers WORKING. 3. **Output silence**: Confirms idle after `completionConfirmMs` (10s) of no new output 4. **Token stability**: Tokens haven't changed 5. **Working patterns absent**: No `Thinking`, `Writing`, spinner chars, etc. for at least 8 seconds diff --git a/install.sh b/install.sh index 4daddc81..5dfac15b 100755 --- a/install.sh +++ b/install.sh @@ -33,7 +33,7 @@ CLAUDE_SEARCH_PATHS=( ) # ============================================================================ -# Color Output (from scripts/screen-manager.sh pattern) +# Color Output # ============================================================================ setup_colors() { @@ -243,10 +243,6 @@ check_tmux() { command -v tmux &>/dev/null } -check_screen() { - command -v screen &>/dev/null -} - check_claude() { # Check PATH first if command -v claude &>/dev/null; then @@ -427,51 +423,6 @@ install_tmux_suse() { run_as_root zypper install -y tmux } -install_screen_macos() { - info "Installing GNU Screen via Homebrew..." - - # macOS has a built-in screen but it's very outdated - if command -v screen &>/dev/null; then - local builtin_version - builtin_version=$(screen --version 2>&1 | head -1 || echo "unknown") - if [[ "$builtin_version" == *"Apple"* ]] || [[ ! "$builtin_version" == *"GNU"* ]]; then - info "Upgrading from macOS built-in screen to GNU Screen..." - fi - fi - - brew install screen -} - -install_screen_debian() { - info "Installing GNU Screen via apt..." - ensure_sudo - run_as_root apt-get update -qq - run_as_root apt-get install -y -qq screen -} - -install_screen_fedora() { - info "Installing GNU Screen via dnf..." - ensure_sudo - run_as_root dnf install -y screen -} - -install_screen_arch() { - info "Installing GNU Screen via pacman..." - ensure_sudo - run_as_root pacman -Sy --noconfirm screen -} - -install_screen_alpine() { - info "Installing GNU Screen via apk..." - run_as_root apk add --no-cache screen -} - -install_screen_suse() { - info "Installing GNU Screen via zypper..." - ensure_sudo - run_as_root zypper install -y screen -} - install_git_macos() { info "Installing Git via Homebrew..." brew install git @@ -642,59 +593,15 @@ setup_sc_alias() { if [[ "$shell_name" == "fish" ]]; then echo "" >> "$profile" - echo "# Claudeman Screens shortcut" >> "$profile" - echo "alias sc='screen-chooser'" >> "$profile" + echo "# Claudeman tmux session shortcut" >> "$profile" + echo "alias sc='tmux-chooser'" >> "$profile" else echo "" >> "$profile" - echo "# Claudeman Screens shortcut" >> "$profile" - echo "alias sc='screen-chooser'" >> "$profile" + echo "# Claudeman tmux session shortcut" >> "$profile" + echo "alias sc='tmux-chooser'" >> "$profile" fi - info "Added 'sc' alias for screen-chooser" -} - -# ============================================================================ -# Screen Configuration -# ============================================================================ - -setup_screenrc() { - local screenrc="$HOME/.screenrc" - - # Check if screenrc already exists and has our config - if [[ -f "$screenrc" ]] && grep -q "mousetrack on" "$screenrc" 2>/dev/null; then - info "Screen configuration already set up" - return 0 - fi - - info "Setting up screen configuration..." - - # Backup existing screenrc if it exists - if [[ -f "$screenrc" ]]; then - cp "$screenrc" "${screenrc}.backup.$(date +%Y%m%d%H%M%S)" - info "Backed up existing .screenrc" - fi - - # Create or append screen config - cat >> "$screenrc" << 'EOF' - -# ============================================================================ -# Claudeman screen configuration -# ============================================================================ - -# Enable mouse tracking for scrolling in mobile terminals (Termius, etc.) -mousetrack on - -# Increase scrollback buffer (default is 100) -defscrollback 10000 - -# Disable startup message -startup_message off - -# Enable alternate screen (better compatibility) -altscreen on -EOF - - success "Screen configuration saved to $screenrc" + info "Added 'sc' alias for tmux-chooser" } # ============================================================================ @@ -849,17 +756,15 @@ main() { die "npm is not available. Please reinstall Node.js." fi - # Terminal multiplexer (tmux preferred, GNU Screen as fallback) - info "Checking terminal multiplexer..." + # Terminal multiplexer (tmux required) + info "Checking tmux..." if check_tmux; then - success "tmux is installed (preferred)" - elif check_screen; then - success "GNU Screen is installed (fallback — consider installing tmux for better performance)" + success "tmux is installed" else if prompt_yes_no "tmux is not installed. Install it now?"; then install_dependency "tmux" "$os" "$distro" else - die "A terminal multiplexer (tmux or GNU Screen) is required for session persistence." + die "tmux is required for session persistence." fi fi @@ -943,10 +848,10 @@ main() { ln -sf "$INSTALL_DIR/dist/index.js" "$symlink_dir/claudeman" info "Created symlink: $symlink_dir/claudeman" - # Install screen-chooser as 'screen-chooser' command - if [[ -f "$INSTALL_DIR/scripts/screen-chooser.sh" ]]; then - ln -sf "$INSTALL_DIR/scripts/screen-chooser.sh" "$symlink_dir/screen-chooser" - info "Created symlink: $symlink_dir/screen-chooser" + # Install tmux-chooser as 'tmux-chooser' command + if [[ -f "$INSTALL_DIR/scripts/tmux-chooser.sh" ]]; then + ln -sf "$INSTALL_DIR/scripts/tmux-chooser.sh" "$symlink_dir/tmux-chooser" + info "Created symlink: $symlink_dir/tmux-chooser" # Add 'sc' alias for quick access setup_sc_alias fi @@ -957,11 +862,6 @@ main() { fi fi - # ======================================================================== - # Screen Configuration - # ======================================================================== - - setup_screenrc # ======================================================================== # Systemd Service (Linux only) @@ -996,7 +896,7 @@ main() { echo "" echo -e " ${BOLD}Mobile Access (Termius/SSH):${NC}" echo "" - echo -e " ${CYAN}sc${NC} # Interactive screen session chooser" + echo -e " ${CYAN}sc${NC} # Interactive tmux session chooser" echo -e " ${CYAN}sc 2${NC} # Quick attach to session 2" echo -e " ${CYAN}sc -h${NC} # Help" echo "" diff --git a/package.json b/package.json index 22a0f0b4..a0061081 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1537", + "version": "0.1538", "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/screen-chooser.sh b/scripts/screen-chooser.sh deleted file mode 100755 index 0b39d70d..00000000 --- a/scripts/screen-chooser.sh +++ /dev/null @@ -1,719 +0,0 @@ -#!/bin/bash -# ============================================================================ -# Claudeman Screens - Mobile-friendly Screen Session Chooser -# Optimized for iPhone/Termius (portrait ~45 chars, landscape ~95 chars) -# ============================================================================ -# -# Design principles: -# - Single-digit selection (1-9) for fast thumb typing -# - Compact display, no wasted space -# - Color-coded status for quick scanning -# - Names pulled from Claudeman state.json -# - Minimal keystrokes to attach -# -# Usage: -# screen-chooser # Interactive chooser -# screen-chooser 1 # Quick attach to session 1 -# screen-chooser -l # List only (non-interactive) -# screen-chooser -h # Help -# -# Alias (added by installer): alias sc='screen-chooser' -# Then: sc (interactive) -# sc 2 (attach session 2) -# -# ============================================================================ - -set -e - -# ============================================================================ -# Configuration -# ============================================================================ - -CLAUDEMAN_STATE="$HOME/.claudeman/state.json" -CLAUDEMAN_SCREENS="$HOME/.claudeman/screens.json" - -# iPhone 17 Pro portrait width (conservative) -MAX_WIDTH=44 -MAX_NAME_LEN=28 - -# Page size for pagination (leave room for header/footer) -PAGE_SIZE=7 - -# Auto-refresh timeout (seconds) - 0 to disable -AUTO_REFRESH=60 - -# ============================================================================ -# Icon Detection (Nerd Fonts vs ASCII) -# ============================================================================ - -# Detect if terminal likely supports nerd fonts -# Termius generally doesn't, so default to ASCII -detect_icons() { - # Check TERM and common indicators - if [[ "$TERM_PROGRAM" == "iTerm"* ]] || \ - [[ "$TERM" == "xterm-kitty" ]] || \ - [[ -n "$WEZTERM_PANE" ]] || \ - [[ "$LC_TERMINAL" == "iTerm2" ]]; then - ICON_SCREEN="󰆍" - ICON_ATTACHED="●" - ICON_DETACHED="○" - ICON_UNKNOWN="◌" - else - # ASCII fallback for Termius/basic terminals - ICON_SCREEN="[S]" - ICON_ATTACHED="*" - ICON_DETACHED="-" - ICON_UNKNOWN="?" - fi -} - -detect_icons - -# ============================================================================ -# Colors - ANSI 256 for better Termius compatibility -# ============================================================================ - -R='\033[0m' # Reset -B='\033[1m' # Bold -D='\033[2m' # Dim -GREEN='\033[38;5;82m' -YELLOW='\033[38;5;220m' -BLUE='\033[38;5;75m' -CYAN='\033[38;5;87m' -RED='\033[38;5;203m' -GRAY='\033[38;5;245m' -WHITE='\033[38;5;255m' -BG_SEL='\033[48;5;236m' # Selection background - -# ============================================================================ -# Utilities -# ============================================================================ - -# Truncate string with ellipsis, keeping end if it looks like a path -truncate() { - local str="$1" - local max="$2" - local len=${#str} - - if [ "$len" -le "$max" ]; then - echo "$str" - return - fi - - # If it's a path-like string, keep the end - if [[ "$str" == *"/"* ]]; then - echo "..${str: -$((max-2))}" - else - echo "${str:0:$((max-1))}…" - fi -} - -# Find full session ID from short ID (screen names use first 8 chars) -find_full_session_id() { - local short_id="$1" - - # Try state.json first - if [ -f "$CLAUDEMAN_STATE" ]; then - local full_id - full_id=$(jq -r --arg short "$short_id" ' - .sessions | keys[] | select(startswith($short)) - ' "$CLAUDEMAN_STATE" 2>/dev/null | head -1) - if [ -n "$full_id" ]; then - echo "$full_id" - return - fi - fi - - # Try screens.json array - if [ -f "$CLAUDEMAN_SCREENS" ]; then - local full_id - full_id=$(jq -r --arg short "$short_id" ' - .[] | select(.sessionId | startswith($short)) | .sessionId - ' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1) - if [ -n "$full_id" ]; then - echo "$full_id" - return - fi - fi - - echo "$short_id" -} - -# Get session name from Claudeman state or screens.json -# Falls back to working directory basename if name is empty -get_session_name() { - local session_id="$1" - local name="" - local workdir="" - - # First, try screens.json (array format) - it has both name and workingDir - if [ -f "$CLAUDEMAN_SCREENS" ]; then - local result - result=$(jq -r --arg id "$session_id" ' - .[] | select(.sessionId | startswith($id)) | "\(.name // "")\t\(.workingDir // "")" - ' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1) - if [ -n "$result" ]; then - name="${result%% *}" - workdir="${result#* }" - fi - fi - - # If no name yet, try state.json - if [ -z "$name" ] && [ -f "$CLAUDEMAN_STATE" ]; then - local result - result=$(jq -r --arg id "$session_id" ' - .sessions | to_entries[] | select(.key | startswith($id)) | "\(.value.name // "")\t\(.value.workingDir // "")" - ' "$CLAUDEMAN_STATE" 2>/dev/null | head -1) - if [ -n "$result" ]; then - name="${result%% *}" - [ -z "$workdir" ] && workdir="${result#* }" - fi - fi - - # If we have a name, use it - if [ -n "$name" ]; then - echo "$name" - return - fi - - # Fallback to working directory basename - if [ -n "$workdir" ]; then - echo "${workdir##*/}" - return - fi - - # Last resort: use short ID - echo "${session_id:0:8}" -} - -# Get working directory from state or screens.json -get_working_dir() { - local session_id="$1" - - # Try screens.json first (array format) - if [ -f "$CLAUDEMAN_SCREENS" ]; then - local dir - dir=$(jq -r --arg id "$session_id" ' - .[] | select(.sessionId | startswith($id)) | .workingDir // empty - ' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1) - if [ -n "$dir" ] && [ "$dir" != "null" ]; then - echo "${dir/#$HOME/~}" - return - fi - fi - - # Try state.json - if [ -f "$CLAUDEMAN_STATE" ]; then - local dir - dir=$(jq -r --arg id "$session_id" ' - .sessions | to_entries[] | select(.key | startswith($id)) | .value.workingDir // empty - ' "$CLAUDEMAN_STATE" 2>/dev/null | head -1) - if [ -n "$dir" ] && [ "$dir" != "null" ]; then - echo "${dir/#$HOME/~}" - return - fi - fi - echo "" -} - -# Get token count from state -get_tokens() { - local session_id="$1" - - if [ -f "$CLAUDEMAN_STATE" ]; then - local tokens - tokens=$(jq -r --arg id "$session_id" ' - .sessions | to_entries[] | select(.key | startswith($id)) | - ((.value.inputTokens // 0) + (.value.outputTokens // 0)) - ' "$CLAUDEMAN_STATE" 2>/dev/null | head -1) - - if [ -n "$tokens" ] && [ "$tokens" != "null" ] && [ "$tokens" -gt 0 ] 2>/dev/null; then - if [ "$tokens" -gt 1000 ]; then - echo "$((tokens / 1000))k" - else - echo "${tokens}" - fi - return - fi - fi - echo "" -} - -# Get respawn status from screens.json (array format) -get_respawn_status() { - local session_id="$1" - - if [ -f "$CLAUDEMAN_SCREENS" ]; then - local respawn_enabled - respawn_enabled=$(jq -r --arg id "$session_id" ' - .[] | select(.sessionId | startswith($id)) | .respawnConfig.enabled // false - ' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1) - - if [ "$respawn_enabled" = "true" ]; then - echo "R" # Respawn active - return - fi - fi - echo "" -} - -# Check if jq is available -check_deps() { - if ! command -v jq &>/dev/null; then - echo -e "${YELLOW}Note: Install jq for session names${R}" - echo "" - fi -} - -# ============================================================================ -# Screen List Parser -# ============================================================================ - -declare -a SCREEN_PIDS -declare -a SCREEN_NAMES -declare -a SCREEN_STATES -declare -a SESSION_IDS -declare -a DISPLAY_NAMES -declare -a WORKING_DIRS -declare -a TOKEN_COUNTS -declare -a RESPAWN_STATUS - -parse_screens() { - SCREEN_PIDS=() - SCREEN_NAMES=() - SCREEN_STATES=() - SESSION_IDS=() - DISPLAY_NAMES=() - WORKING_DIRS=() - TOKEN_COUNTS=() - RESPAWN_STATUS=() - - local i=0 - - # Regex for screen -ls lines: " 12345.claudeman-abc123 (date) (Attached)" - # We need to capture: PID, name, and the LAST parenthesized group (state) - # Modern screen -ls shows: PID.name (date) (Attached|Detached) - local screen_regex='^[[:space:]]*([0-9]+)\.([^[:space:]]+).*\((Attached|Detached|Multi)\)' - - # Parse screen -ls output - while IFS= read -r line; do - if [[ "$line" =~ $screen_regex ]]; then - local pid="${BASH_REMATCH[1]}" - local name="${BASH_REMATCH[2]}" - local state="${BASH_REMATCH[3]}" - - SCREEN_PIDS+=("$pid") - SCREEN_NAMES+=("$name") - SCREEN_STATES+=("$state") - - # Extract session ID from claudeman screen name - local session_id="" - local cm_regex='^claudeman-(.+)$' - if [[ "$name" =~ $cm_regex ]]; then - session_id="${BASH_REMATCH[1]}" - fi - SESSION_IDS+=("$session_id") - - # Get display name and metadata - if [ -n "$session_id" ]; then - DISPLAY_NAMES+=("$(get_session_name "$session_id")") - WORKING_DIRS+=("$(get_working_dir "$session_id")") - TOKEN_COUNTS+=("$(get_tokens "$session_id")") - RESPAWN_STATUS+=("$(get_respawn_status "$session_id")") - else - DISPLAY_NAMES+=("$name") - WORKING_DIRS+=("") - TOKEN_COUNTS+=("") - RESPAWN_STATUS+=("") - fi - - i=$((i + 1)) - fi - done < <(screen -ls 2>/dev/null || true) -} - -# ============================================================================ -# Display Functions -# ============================================================================ - -clear_screen() { - printf '\033[2J\033[H' -} - -# Print header -print_header() { - local count=${#SCREEN_PIDS[@]} - echo -e "${B}${CYAN}Claudeman Screens${R} ${D}($count)${R}" - echo -e "${D}$(printf '%.0s─' {1..32})${R}" -} - -# Print a session entry -print_entry() { - local idx="$1" - local num=$((idx + 1)) - local name="${DISPLAY_NAMES[$idx]}" - local state="${SCREEN_STATES[$idx]}" - local dir="${WORKING_DIRS[$idx]}" - local tokens="${TOKEN_COUNTS[$idx]}" - local respawn="${RESPAWN_STATUS[$idx]}" - - # Truncate name (leave room for indicators) - local name_max=$MAX_NAME_LEN - [ -n "$respawn" ] && name_max=$((name_max - 2)) - [ -n "$tokens" ] && name_max=$((name_max - 4)) - name=$(truncate "$name" $name_max) - - # Status indicator and color - local status_icon status_color - if [[ "$state" == *"Attached"* ]]; then - status_icon="$ICON_ATTACHED" - status_color="$GREEN" - elif [[ "$state" == *"Detached"* ]]; then - status_icon="$ICON_DETACHED" - status_color="$GRAY" - else - status_icon="$ICON_UNKNOWN" - status_color="$YELLOW" - fi - - # Build the line - # Format: "1) name ○ R 45k" - local num_str="${B}${WHITE}${num})${R}" - local name_str="${B}${WHITE}${name}${R}" - local status_str="${status_color}${status_icon}${R}" - - # Respawn indicator (green R if active) - local respawn_str="" - if [ -n "$respawn" ]; then - respawn_str=" ${GREEN}${respawn}${R}" - fi - - # Token display - local token_str="" - if [ -n "$tokens" ]; then - token_str=" ${D}${tokens}${R}" - fi - - echo -e " ${num_str} ${name_str} ${status_str}${respawn_str}${token_str}" - - # Show directory on second line if present (dimmed, indented) - if [ -n "$dir" ]; then - dir=$(truncate "$dir" $((MAX_NAME_LEN - 2))) - echo -e " ${D}${dir}${R}" - fi -} - -# Print footer with commands -print_footer() { - local page="$1" - local total_pages="$2" - - echo "" - echo -e "${D}────────────────────────────────${R}" - - # Pagination indicator - if [ "$total_pages" -gt 1 ]; then - echo -e " ${D}Page $((page+1))/$total_pages${R} ${GRAY}[${WHITE}n${GRAY}]ext [${WHITE}p${GRAY}]rev${R}" - fi - - # Commands - most used first, thumb-friendly - echo -e " ${GRAY}[${WHITE}1-9${GRAY}]attach [${WHITE}r${GRAY}]efresh [${WHITE}q${GRAY}]uit${R}" -} - -# Print no screens message -print_no_screens() { - clear_screen - echo -e "${B}${CYAN}Claudeman Screens${R}" - echo -e "${D}$(printf '%.0s─' {1..32})${R}" - echo "" - echo -e " ${YELLOW}No screen sessions found${R}" - echo "" - echo -e " ${D}Start one with:${R}" - echo -e " ${WHITE}claudeman web${R}" - echo "" - echo -e "${D}$(printf '%.0s─' {1..32})${R}" - echo -e " ${GRAY}[${WHITE}r${GRAY}]efresh [${WHITE}q${GRAY}]uit${R}" -} - -# ============================================================================ -# Main Display Loop -# ============================================================================ - -current_page=0 - -render() { - clear_screen - parse_screens - - local count=${#SCREEN_PIDS[@]} - - if [ "$count" -eq 0 ]; then - print_no_screens - return - fi - - local total_pages=$(( (count + PAGE_SIZE - 1) / PAGE_SIZE )) - - # Clamp page - if [ "$current_page" -ge "$total_pages" ]; then - current_page=$((total_pages - 1)) - fi - if [ "$current_page" -lt 0 ]; then - current_page=0 - fi - - local start=$((current_page * PAGE_SIZE)) - local end=$((start + PAGE_SIZE)) - if [ "$end" -gt "$count" ]; then - end=$count - fi - - print_header - echo "" - - for ((i = start; i < end; i++)); do - print_entry $i - done - - print_footer $current_page $total_pages -} - -# Attach to a screen -attach_screen() { - local idx="$1" - local pid="${SCREEN_PIDS[$idx]}" - local name="${SCREEN_NAMES[$idx]}" - local state="${SCREEN_STATES[$idx]}" - - if [ -z "$pid" ]; then - return 1 - fi - - clear_screen - echo -e "${GREEN}Attaching to ${B}${DISPLAY_NAMES[$idx]}${R}${GREEN}...${R}" - echo -e "${D}(Ctrl+A D to detach)${R}" - sleep 0.3 - - # IMPORTANT: Claudeman attaches to screen via PTY with -x for streaming. - # Using -rd here would detach Claudeman and break terminal output. - # - # For mobile access without squeezing the desktop view: - # - Use the web UI from mobile Safari (responsive, works on iPhone) - # - The web UI at http://yourserver:3000 works great on mobile browsers - # - # If you attach via screen -x, the terminal will resize to the smallest - # attached client (your mobile). Use Ctrl+A D to detach when done. - if [[ "$state" == "Attached" ]] || [[ "$state" == "Multi" ]]; then - screen -x "$pid.$name" - else - screen -r "$pid.$name" - fi - - # After detach, return to chooser - return 0 -} - -# ============================================================================ -# Input Handler -# ============================================================================ - -handle_input() { - local key="$1" - local count=${#SCREEN_PIDS[@]} - local total_pages=$(( (count + PAGE_SIZE - 1) / PAGE_SIZE )) - - case "$key" in - # Number selection (1-9) - [1-9]) - local idx=$((key - 1)) - if [ "$idx" -lt "$count" ]; then - attach_screen "$idx" - return 0 - fi - ;; - - # Escape sequence (arrow keys) - $'\e') - # Read the rest of the escape sequence - read -rsn2 -t 0.1 seq 2>/dev/null || true - case "$seq" in - '[A'|'[D') # Up or Left arrow - if [ "$total_pages" -gt 1 ]; then - current_page=$(( (current_page - 1 + total_pages) % total_pages )) - fi - ;; - '[B'|'[C') # Down or Right arrow - if [ "$total_pages" -gt 1 ]; then - current_page=$(( (current_page + 1) % total_pages )) - fi - ;; - esac - ;; - - # Navigation (vim-style and standard) - n|N|j|J) - if [ "$total_pages" -gt 1 ]; then - current_page=$(( (current_page + 1) % total_pages )) - fi - ;; - - p|P|k|K) - if [ "$total_pages" -gt 1 ]; then - current_page=$(( (current_page - 1 + total_pages) % total_pages )) - fi - ;; - - # Refresh - r|R) - # Just re-render - ;; - - # Quit - q|Q) - clear_screen - exit 0 - ;; - - # Enter with no selection - attach first if only one - '') - if [ "$count" -eq 1 ]; then - attach_screen 0 - return 0 - fi - ;; - esac - - return 0 -} - -# ============================================================================ -# List Mode (non-interactive, for scripting) -# ============================================================================ - -list_mode() { - parse_screens - local count=${#SCREEN_PIDS[@]} - - if [ "$count" -eq 0 ]; then - echo "No screen sessions" - exit 0 - fi - - for ((i = 0; i < count; i++)); do - local num=$((i + 1)) - local name="${DISPLAY_NAMES[$i]}" - local state="${SCREEN_STATES[$i]}" - local respawn="${RESPAWN_STATUS[$i]}" - local indicator="-" - [[ "$state" == *"Attached"* ]] && indicator="*" - [ -n "$respawn" ] && indicator="${indicator}R" - - echo "$num) $name [$indicator]" - done -} - -# ============================================================================ -# Quick Attach (by number) -# ============================================================================ - -quick_attach() { - local num="$1" - parse_screens - - local count=${#SCREEN_PIDS[@]} - local idx=$((num - 1)) - - if [ "$idx" -lt 0 ] || [ "$idx" -ge "$count" ]; then - echo -e "${RED}Invalid session: $num${R}" - echo "Available: 1-$count" - exit 1 - fi - - attach_screen "$idx" -} - -# ============================================================================ -# Help -# ============================================================================ - -show_help() { - cat << 'EOF' -Claudeman Screens - Mobile-friendly Screen Session Chooser - -USAGE: - sc Interactive chooser - sc Quick attach to session N - sc -l List sessions (non-interactive) - sc -h Show this help - -INTERACTIVE KEYS: - 1-9 Attach to session - n/j/↓ Next page - p/k/↑ Previous page - r Refresh - q Quit - -INDICATORS: - * / ● Attached (someone connected) - - / ○ Detached (available) - R Respawn enabled - 45k Token count - -TIPS: - - Detach from screen: Ctrl+A D - - Session names from Claudeman state - - Optimized for Termius/iPhone - -EOF -} - -# ============================================================================ -# Main -# ============================================================================ - -main() { - # Argument parsing - case "${1:-}" in - -h|--help) - show_help - exit 0 - ;; - -l|--list) - list_mode - exit 0 - ;; - [1-9]|[1-9][0-9]) - # Quick attach by number - quick_attach "$1" - exit $? - ;; - esac - - # Check dependencies - check_deps - - # Initial render - render - - # Input loop - while true; do - # Read single character without enter - # -s: silent, -n1: one char, -t: timeout for auto-refresh - local timeout_opt="" - if [ "$AUTO_REFRESH" -gt 0 ]; then - timeout_opt="-t $AUTO_REFRESH" - fi - - if read -rsn1 $timeout_opt key 2>/dev/null; then - handle_input "$key" - fi - # Re-render after input or timeout (auto-refresh) - render - done -} - -# Handle Ctrl+C gracefully -trap 'clear_screen; exit 0' INT - -# Run -main "$@" diff --git a/scripts/screen-manager.sh b/scripts/screen-manager.sh deleted file mode 100755 index abc67bbd..00000000 --- a/scripts/screen-manager.sh +++ /dev/null @@ -1,494 +0,0 @@ -#!/bin/bash -# -# Claudeman Screen Session Manager -# Interactive tool with arrow key navigation -# Reads from ~/.claudeman/screens.json -# - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -MAGENTA='\033[0;35m' -BOLD='\033[1m' -DIM='\033[2m' -NC='\033[0m' -REVERSE='\033[7m' - -# Use the same path as claudeman (src/screen-manager.ts) -SCREENS_FILE="${HOME}/.claudeman/screens.json" - -# Cached data -CACHED_JSON="" -CACHED_COUNT=0 -LAST_REFRESH=0 -REFRESH_INTERVAL=5 # Refresh data every 5 seconds - -# Cleanup on exit -cleanup() { - tput cnorm 2>/dev/null - stty echo 2>/dev/null - echo "" -} -trap cleanup EXIT - -# Check dependencies -check_dependencies() { - if ! command -v jq &> /dev/null; then - echo -e "${RED}Error: 'jq' is required. Install with: sudo apt install jq${NC}" - exit 1 - fi - if ! command -v screen &> /dev/null; then - echo -e "${RED}Error: 'screen' is required. Install with: sudo apt install screen${NC}" - exit 1 - fi -} - -# Refresh cached data if needed -refresh_cache() { - local now=$(date +%s) - if [[ $((now - LAST_REFRESH)) -ge $REFRESH_INTERVAL ]] || [[ -z "$CACHED_JSON" ]]; then - if [[ -f "$SCREENS_FILE" ]]; then - CACHED_JSON=$(cat "$SCREENS_FILE") - else - CACHED_JSON="[]" - fi - CACHED_COUNT=$(echo "$CACHED_JSON" | jq 'length') - LAST_REFRESH=$now - fi -} - -# Force refresh -force_refresh() { - LAST_REFRESH=0 - refresh_cache -} - -get_session_field() { - local index=$1 field=$2 - echo "$CACHED_JSON" | jq -r ".[$index].$field // \"unknown\"" -} - -# Format duration -format_duration() { - local s=$1 d=$((s/86400)) h=$(((s%86400)/3600)) m=$(((s%3600)/60)) - if [[ $d -gt 0 ]]; then printf "%dd %dh" $d $h - elif [[ $h -gt 0 ]]; then printf "%dh %dm" $h $m - elif [[ $m -gt 0 ]]; then printf "%dm" $m - else printf "%ds" $s; fi -} - -# Check if screen alive (cached per draw cycle) -declare -A ALIVE_CACHE -check_alive() { - local screen_name=$1 - if [[ -z "${ALIVE_CACHE[$screen_name]+x}" ]]; then - if screen -ls 2>/dev/null | grep -q "$screen_name"; then - ALIVE_CACHE[$screen_name]=1 - else - ALIVE_CACHE[$screen_name]=0 - fi - fi - [[ "${ALIVE_CACHE[$screen_name]}" -eq 1 ]] -} - -clear_alive_cache() { - ALIVE_CACHE=() -} - -# Kill session by index -kill_session() { - local idx=$1 - local screen_name=$(get_session_field $idx "screenName") - local pid=$(get_session_field $idx "pid") - - pkill -TERM -P $pid 2>/dev/null - kill -TERM -$pid 2>/dev/null - screen -S "$screen_name" -X quit 2>/dev/null - kill -KILL $pid 2>/dev/null - - # Remove from JSON - CACHED_JSON=$(echo "$CACHED_JSON" | jq "del(.[$idx])") - echo "$CACHED_JSON" > "$SCREENS_FILE" - CACHED_COUNT=$(echo "$CACHED_JSON" | jq 'length') - clear_alive_cache -} - -# Draw header (only once) -draw_header() { - echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" - echo -e "${BOLD}${CYAN}║ Claudeman Screen Session Manager ║${NC}" - echo -e "${BOLD}${CYAN}║ ${DIM}Press q or Esc to exit${CYAN} ║${NC}" - echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════════════╝${NC}" - echo "" -} - -# Draw a single session row (no_newline=1 for partial updates) -draw_row() { - local i=$1 - local selected=$2 - local no_newline=${3:-0} - local now=$(date +%s) - - local name=$(echo "$CACHED_JSON" | jq -r ".[$i].name // \"unnamed\"") - local screen_name=$(echo "$CACHED_JSON" | jq -r ".[$i].screenName") - local created_at=$(echo "$CACHED_JSON" | jq -r ".[$i].createdAt") - local mode=$(echo "$CACHED_JSON" | jq -r ".[$i].mode") - - local created_s=$((created_at / 1000)) - local elapsed=$((now - created_s)) - local running=$(format_duration $elapsed) - - local status status_color - if check_alive "$screen_name"; then - status="● alive" - status_color="${GREEN}" - else - status="○ dead" - status_color="${RED}" - fi - - [[ ${#name} -gt 20 ]] && name="${name:0:17}..." - - local line_end=$'\n' - [[ $no_newline -eq 1 ]] && line_end="" - - if [[ $i -eq $selected ]]; then - printf " ${REVERSE}${BOLD}▶ %-20s %-14s ${status_color}%-12s${NC}${REVERSE} %-8s ${NC}%s" \ - "$name" "$running" "$status" "$mode" "$line_end" - else - printf " %-20s %-14s ${status_color}%-12s${NC} %-8s%s" \ - "$name" "$running" "$status" "$mode" "$line_end" - fi -} - -# Draw footer -draw_footer() { - echo "" - echo -e " ${DIM}──────────────────────────────────────────────────────────────${NC}" - echo -e " ${GREEN}↑/↓${NC} Navigate ${GREEN}Enter${NC} Attach ${YELLOW}d${NC} Delete ${RED}D${NC} Delete All ${BLUE}i${NC} Info ${DIM}q${NC} Quit" -} - -# Full redraw -full_redraw() { - local selected=$1 - clear - draw_header - - if [[ $CACHED_COUNT -eq 0 ]]; then - echo -e " ${YELLOW}No sessions found${NC}" - echo -e " ${DIM}(checking: $SCREENS_FILE)${NC}" - echo "" - echo -e " ${DIM}Press q to quit${NC}" - return - fi - - printf " ${BOLD}%-22s %-14s %-12s %-8s${NC}\n" "NAME" "RUNNING" "STATUS" "MODE" - echo -e " ${DIM}──────────────────────────────────────────────────────────────${NC}" - - for ((i=0; i/dev/null || echo "unknown") - - clear - echo -e "${BOLD}${CYAN}Session Details${NC}" - echo -e "${DIM}────────────────────────────────────────${NC}" - echo -e " ${BOLD}Name:${NC} $name" - echo -e " ${BOLD}Session ID:${NC} $session_id" - echo -e " ${BOLD}Screen:${NC} $screen_name" - echo -e " ${BOLD}PID:${NC} $pid" - echo -e " ${BOLD}Mode:${NC} $mode" - echo -e " ${BOLD}Directory:${NC} $working_dir" - echo -e " ${BOLD}Started:${NC} $started" - echo -e " ${BOLD}Running:${NC} $(format_duration $elapsed)" - - if check_alive "$screen_name"; then - echo -e " ${BOLD}Status:${NC} ${GREEN}● alive${NC}" - local mem=$(ps -o rss= -p $pid 2>/dev/null | tr -d ' ') - [[ -n "$mem" ]] && echo -e " ${BOLD}Memory:${NC} $(echo "scale=1; $mem/1024" | bc) MB" - else - echo -e " ${BOLD}Status:${NC} ${RED}○ dead${NC}" - fi - - echo "" - echo -e "${DIM}Press any key to continue...${NC}" - read -rsn1 -} - -# Confirm dialog -confirm() { - local msg=$1 - tput sc # Save cursor - tput cup $((CACHED_COUNT + 12)) 0 - echo -en " ${YELLOW}$msg (y/n):${NC} " - read -rsn1 answer - tput rc # Restore cursor - [[ "$answer" == "y" || "$answer" == "Y" ]] -} - -# Read single keypress (handles arrow keys) -read_key() { - local key - IFS= read -rsn1 key - - if [[ "$key" == $'\x1b' ]]; then - read -rsn2 -t 0.1 key - case "$key" in - '[A') echo "UP" ;; - '[B') echo "DOWN" ;; - *) echo "ESC" ;; - esac - elif [[ "$key" == "" ]]; then - echo "ENTER" - else - echo "$key" - fi -} - -# Main interactive loop -interactive_menu() { - local selected=0 - local need_full_redraw=1 - - tput civis # Hide cursor - force_refresh - clear_alive_cache - full_redraw $selected - need_full_redraw=0 - - while true; do - # Adjust selection if out of bounds - [[ $selected -ge $CACHED_COUNT ]] && selected=$((CACHED_COUNT > 0 ? CACHED_COUNT - 1 : 0)) - [[ $selected -lt 0 ]] && selected=0 - - # Redraw if needed - if [[ $need_full_redraw -eq 1 ]]; then - clear_alive_cache - full_redraw $selected - need_full_redraw=0 - fi - - local key=$(read_key) - local old_selected=$selected - - case "$key" in - UP|k) - if [[ $selected -gt 0 ]]; then - ((selected--)) - [[ $CACHED_COUNT -gt 0 ]] && update_selection $old_selected $selected - fi - ;; - DOWN|j) - if [[ $selected -lt $((CACHED_COUNT - 1)) ]]; then - ((selected++)) - [[ $CACHED_COUNT -gt 0 ]] && update_selection $old_selected $selected - fi - ;; - ENTER) - if [[ $CACHED_COUNT -gt 0 ]]; then - local screen_name=$(get_session_field $selected "screenName") - if check_alive "$screen_name"; then - tput cnorm - clear - echo -e "${CYAN}Attaching... (Ctrl+A D to detach)${NC}" - sleep 0.3 - screen -r "$screen_name" - tput civis - need_full_redraw=1 - force_refresh - else - tput cup $((CACHED_COUNT + 12)) 0 - echo -e " ${RED}Session is dead${NC} " - sleep 1 - need_full_redraw=1 - fi - fi - ;; - d|x) - if [[ $CACHED_COUNT -gt 0 ]]; then - local name=$(get_session_field $selected "name") - if confirm "Kill '$name'?"; then - kill_session $selected - need_full_redraw=1 - else - need_full_redraw=1 - fi - fi - ;; - D|X) - if [[ $CACHED_COUNT -gt 0 ]]; then - if confirm "Kill ALL $CACHED_COUNT sessions?"; then - for ((i=CACHED_COUNT-1; i>=0; i--)); do - kill_session $i - done - fi - need_full_redraw=1 - fi - ;; - i) - if [[ $CACHED_COUNT -gt 0 ]]; then - show_info $selected - need_full_redraw=1 - fi - ;; - q|Q|ESC) - clear - echo -e "${DIM}Goodbye!${NC}" - break - ;; - esac - done - - tput cnorm -} - -# Quick list (non-interactive) -quick_list() { - force_refresh - local now=$(date +%s) - - if [[ $CACHED_COUNT -eq 0 ]]; then - echo "No claudeman sessions found." - exit 0 - fi - - echo "Claudeman Sessions ($SCREENS_FILE):" - echo "" - printf "%-4s %-22s %-14s %-10s %-8s\n" "#" "NAME" "RUNNING" "STATUS" "MODE" - echo "─────────────────────────────────────────────────────────────────" - - for ((i=0; i"; exit 1; } - force_refresh - local screen_name=$(get_session_field $(($2-1)) "screenName") - check_alive "$screen_name" && screen -r "$screen_name" || echo "Session dead or not found" - ;; - kill) - [[ -z "${2:-}" ]] && { echo "Usage: $0 kill "; exit 1; } - force_refresh - if [[ "$2" =~ ^[0-9]+-[0-9]+$ ]]; then - local start="${2%-*}" end="${2#*-}" - for ((i=end-1; i>=start-1; i--)); do kill_session $i 2>/dev/null; done - else - IFS=',' read -ra nums <<< "$2" - for num in $(echo "${nums[*]}" | tr ' ' '\n' | sort -rn); do - kill_session $((num-1)) 2>/dev/null - done - fi - echo "Done" - ;; - kill-all) - force_refresh - for ((i=CACHED_COUNT-1; i>=0; i--)); do kill_session $i; done - echo "All sessions killed" - ;; - info) - [[ -z "${2:-}" ]] && { echo "Usage: $0 info "; exit 1; } - force_refresh - show_info $(($2-1)) - ;; - help|--help|-h) usage ;; - *) echo "Unknown: $1"; usage; exit 1 ;; - esac -} - -main "$@" diff --git a/scripts/tmux-chooser.sh b/scripts/tmux-chooser.sh index 2d2fca06..908e33ed 100755 --- a/scripts/tmux-chooser.sh +++ b/scripts/tmux-chooser.sh @@ -32,10 +32,6 @@ set -e CLAUDEMAN_STATE="$HOME/.claudeman/state.json" CLAUDEMAN_SESSIONS="$HOME/.claudeman/mux-sessions.json" -# Fallback to legacy screens.json -if [[ ! -f "$CLAUDEMAN_SESSIONS" ]] && [[ -f "$HOME/.claudeman/screens.json" ]]; then - CLAUDEMAN_SESSIONS="$HOME/.claudeman/screens.json" -fi # iPhone 17 Pro portrait width (conservative) MAX_WIDTH=44 diff --git a/scripts/tmux-manager.sh b/scripts/tmux-manager.sh index 1353603b..a5f25757 100755 --- a/scripts/tmux-manager.sh +++ b/scripts/tmux-manager.sh @@ -20,10 +20,6 @@ REVERSE='\033[7m' # Use the same path as claudeman (src/tmux-manager.ts) SESSIONS_FILE="${HOME}/.claudeman/mux-sessions.json" -# Fallback to legacy screens.json if mux-sessions.json doesn't exist -if [[ ! -f "$SESSIONS_FILE" ]] && [[ -f "${HOME}/.claudeman/screens.json" ]]; then - SESSIONS_FILE="${HOME}/.claudeman/screens.json" -fi # Cached data CACHED_JSON="" diff --git a/src/ai-checker-base.ts b/src/ai-checker-base.ts index 433c322a..e6bcfa80 100644 --- a/src/ai-checker-base.ts +++ b/src/ai-checker-base.ts @@ -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 diff --git a/src/ai-idle-checker.ts b/src/ai-idle-checker.ts index 629050b1..e565604c 100644 --- a/src/ai-idle-checker.ts +++ b/src/ai-idle-checker.ts @@ -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- bash -c 'claude -p ...'` + * 2. Spawn tmux: `tmux new-session -d -s claudeman-aicheck- 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]'; diff --git a/src/ai-plan-checker.ts b/src/ai-plan-checker.ts index baaebee6..7a0376e0 100644 --- a/src/ai-plan-checker.ts +++ b/src/ai-plan-checker.ts @@ -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- bash -c 'claude -p ...'` + * 2. Spawn tmux: `tmux new-session -d -s claudeman-plancheck- 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]'; diff --git a/src/mux-factory.ts b/src/mux-factory.ts index 4429772b..07bb9a13 100644 --- a/src/mux-factory.ts +++ b/src/mux-factory.ts @@ -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(); } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index bdb32bb6..f921e6fa 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -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; @@ -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[]; diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index e9859db8..59fd81b2 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -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', }); diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index cdd03c51..0a319864 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -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; diff --git a/src/screen-manager.ts b/src/screen-manager.ts deleted file mode 100644 index 249af3b7..00000000 --- a/src/screen-manager.ts +++ /dev/null @@ -1,1004 +0,0 @@ -/** - * @fileoverview GNU Screen session manager for persistent Claude sessions. - * - * This module provides the ScreenManager class which creates and manages - * GNU Screen sessions that wrap Claude CLI processes. Screen provides: - * - * - **Persistence**: Sessions survive server restarts and disconnects - * - **Ghost recovery**: Orphaned screens are discovered and reattached on startup - * - **Resource tracking**: Memory, CPU, and child process stats per session - * - **Reliable input**: `screen -X stuff` bypasses PTY for programmatic commands - * - * Screen sessions are named `claudeman-{sessionId}` and stored in ~/.claudeman/screens.json. - * - * @module screen-manager - */ - -import { EventEmitter } from 'node:events'; -import { spawn, execSync } from 'node:child_process'; -import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; -import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js'; -import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js'; - -// ============================================================================ -// Claude CLI PATH Resolution -// ============================================================================ - -// Claude CLI PATH resolution — shared utility -import { findClaudeDir } from './utils/claude-cli-resolver.js'; - -// ============================================================================ -// Timing Constants -// ============================================================================ - -/** Timeout for exec commands (5 seconds) */ -const EXEC_TIMEOUT_MS = 5000; - -/** Delay after screen creation (500ms) */ -const SCREEN_CREATION_WAIT_MS = 500; - -/** Delay after screen kill command (200ms) */ -const SCREEN_KILL_WAIT_MS = 200; - -/** Delay for graceful shutdown (100ms) */ -const GRACEFUL_SHUTDOWN_WAIT_MS = 100; - -/** Default stats collection interval (2 seconds) */ -const DEFAULT_STATS_INTERVAL_MS = 2000; - -/** Maximum retry attempts for carriage return (3) */ -const CR_MAX_ATTEMPTS = 3; - -/** - * SAFETY: Test mode detection. - * When running under vitest, ALL screen shell commands are disabled. - * ScreenManager becomes a pure in-memory mock. - */ -const IS_TEST_MODE = !!process.env.VITEST; - -// 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'; - -/** Path to persisted screen session metadata */ -const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json'); - -/** Pre-compiled regex for parsing `screen -ls` output */ -const SCREEN_PATTERN = /(\d+)\.(claudeman-([a-f0-9-]+))/g; - -/** 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; - } - // Check for path traversal attempts (prevents escaping workingDir) - if (path.includes('..')) { - 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. - * - * The ScreenManager maintains a registry of screen sessions, creates new ones, - * kills them using a 4-strategy approach, and discovers orphaned "ghost" screens - * from previous runs. - * - * @example - * ```typescript - * const manager = new ScreenManager(); - * - * // Create a screen session for Claude - * const screen = await manager.createScreen(sessionId, '/project', 'claude'); - * - * // Send input to the screen - * manager.sendInput(sessionId, '/clear\r'); - * - * // Kill when done - * await manager.killScreen(sessionId); - * ``` - * - * @fires ScreenManager#screenCreated - New screen session created - * @fires ScreenManager#screenKilled - Screen session terminated - */ -export class ScreenManager extends EventEmitter implements TerminalMultiplexer { - readonly backend = 'screen' as const; - private screens: Map = new Map(); - private statsInterval: NodeJS.Timeout | null = null; - - constructor() { - super(); - if (!IS_TEST_MODE) { - this.loadScreens(); - } - } - - // Load saved screens from disk (NEVER called in test mode) - private loadScreens(): void { - if (IS_TEST_MODE) return; - try { - if (existsSync(SCREENS_FILE)) { - const content = readFileSync(SCREENS_FILE, 'utf-8'); - const data = JSON.parse(content); - if (Array.isArray(data)) { - for (const screen of data) { - this.screens.set(screen.sessionId, screen); - } - } - } - } catch (err) { - console.error('[ScreenManager] Failed to load screens:', err); - } - } - - /** - * Save screens to disk asynchronously. - * Uses async write to avoid blocking the event loop. - */ - private saveScreens(): void { - if (IS_TEST_MODE) return; - try { - const dir = dirname(SCREENS_FILE); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - const data = Array.from(this.screens.values()); - const json = JSON.stringify(data, null, 2); - - // Async write to avoid blocking event loop - writeFile(SCREENS_FILE, json, (err) => { - if (err) { - console.error('[ScreenManager] Failed to save screens:', err); - } - }); - } catch (err) { - console.error('[ScreenManager] Failed to save screens:', err); - } - } - - /** - * Creates a new GNU Screen session wrapping Claude CLI or a shell. - * - * The screen is created in detached mode and automatically starts the - * appropriate command based on the mode parameter. - * - * @param sessionId - Unique session identifier (used in screen name) - * @param workingDir - Working directory for the screen session - * @param mode - 'claude' for Claude CLI or 'shell' for bash - * @param name - Optional display name for the session - * @param niceConfig - Optional nice priority configuration - */ - async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell', name?: string, niceConfig?: NiceConfig): Promise { - const screenName = `claudeman-${sessionId.slice(0, 8)}`; - - // TEST MODE: Create in-memory only - if (IS_TEST_MODE) { - const screen: ScreenSession = { - sessionId, - screenName, - pid: 99999, - createdAt: Date.now(), - workingDir, - mode, - attached: false, - name, - }; - this.screens.set(sessionId, screen); - this.emit('screenCreated', screen); - return screen; - } - - // 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 - const claudeDir = findClaudeDir(); - const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : ''; - - // Environment variables must be exported, not passed inline to nice - // Using inline VAR=value before nice doesn't work correctly - const envExports = [ - 'export CLAUDEMAN_SCREEN=1', - `export CLAUDEMAN_SESSION_ID=${sessionId}`, - `export CLAUDEMAN_SCREEN_NAME=${screenName}`, - `export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`, - ].join(' && '); - - // Base command for the mode (just the executable, env vars are exported separately) - // For Claude sessions: pass --session-id to use the SAME ID as the Claudeman session - // This ensures subagents (discovered via file path) can be directly matched to the correct tab - // Note: sessionId is quoted for safety even though it should be a valid UUID - const baseCmd = mode === 'claude' - ? `claude --dangerously-skip-permissions --session-id "${sessionId}"` - : '$SHELL'; - - // Apply nice priority if configured - const config = niceConfig || DEFAULT_NICE_CONFIG; - const cmd = wrapWithNice(baseCmd, config); - - try { - // Start screen in detached mode - // Order: cd to dir, export PATH, export env vars, then run command - const fullCmd = `cd "${workingDir}" && ${pathExport}${envExports} && ${cmd}`; - - const screenProcess = spawn('screen', [ - '-dmS', screenName, - '-c', '/dev/null', // Use empty config - 'bash', '-c', fullCmd - ], { - cwd: workingDir, - detached: true, - stdio: 'ignore' - }); - - screenProcess.unref(); - - // Wait a moment for screen to start - await new Promise(resolve => setTimeout(resolve, SCREEN_CREATION_WAIT_MS)); - - // Get the PID of the screen session - const pid = this.getScreenPid(screenName); - if (!pid) { - throw new Error('Failed to get screen PID'); - } - - const screen: ScreenSession = { - sessionId, - screenName, - pid, - createdAt: Date.now(), - workingDir, - mode, - attached: false, - name - }; - - this.screens.set(sessionId, screen); - this.saveScreens(); - this.emit('sessionCreated', screen); - - return screen; - } catch (err) { - throw new Error(`Failed to create screen: ${getErrorMessage(err)}`); - } - } - - // 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 { - // Use shell-escaped screenName in grep - const escapedName = shellEscape(screenName); - const output = execSync(`screen -ls | grep "${escapedName}"`, { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS - }); - // Output format: "12345.claudeman-abc12345 (Detached)" - const match = output.match(/(\d+)\./); - return match ? parseInt(match[1], 10) : null; - } catch { - return null; - } - } - - // Get all child process PIDs recursively - private getChildPids(pid: number): number[] { - const pids: number[] = []; - try { - const output = execSync(`pgrep -P ${pid}`, { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS - }).trim(); - if (output) { - for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !Number.isNaN(p))) { - pids.push(childPid); - // Recursively get grandchildren - pids.push(...this.getChildPids(childPid)); - } - } - } catch { - // No children or command failed - } - return pids; - } - - // Check if a process is still alive - private isProcessAlive(pid: number): boolean { - try { - // signal 0 doesn't kill, just checks if process exists - process.kill(pid, 0); - return true; - } catch { - return false; - } - } - - // Verify all PIDs are dead, with retry - private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise { - const startTime = Date.now(); - // 100ms interval balances responsiveness with CPU usage (was 50ms, too aggressive) - const checkInterval = 100; - - while (Date.now() - startTime < maxWaitMs) { - const aliveCount = pids.filter(pid => this.isProcessAlive(pid)).length; - if (aliveCount === 0) { - return true; - } - await new Promise(resolve => setTimeout(resolve, checkInterval)); - } - - // Log any processes that are still alive - const stillAlive = pids.filter(pid => this.isProcessAlive(pid)); - if (stillAlive.length > 0) { - console.warn(`[ScreenManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`); - } - return stillAlive.length === 0; - } - - // Kill a screen session and all its child processes - async killScreen(sessionId: string): Promise { - const screen = this.screens.get(sessionId); - if (!screen) { - return false; - } - - // TEST MODE: Remove from memory only - if (IS_TEST_MODE) { - this.screens.delete(sessionId); - this.emit('screenKilled', { sessionId }); - return true; - } - - // Get current PID from screen -ls in case it changed - const currentPid = this.getScreenPid(screen.screenName) || screen.pid; - - console.log(`[ScreenManager] Killing screen ${screen.screenName} (PID ${currentPid})`); - - // Collect all PIDs to track (for verification) - const allPids: number[] = [currentPid]; - - // Strategy 1: Find and kill all child processes recursively - // Re-check for children before each kill attempt (they may have changed) - let childPids = this.getChildPids(currentPid); - if (childPids.length > 0) { - console.log(`[ScreenManager] Found ${childPids.length} child processes to kill`); - allPids.push(...childPids); - - // Kill children in reverse order (deepest first) with SIGTERM - for (const childPid of [...childPids].reverse()) { - if (this.isProcessAlive(childPid)) { - try { - process.kill(childPid, 'SIGTERM'); - } catch { - // Process may already be dead - } - } - } - - // Give processes a moment to terminate gracefully - await new Promise(resolve => setTimeout(resolve, SCREEN_KILL_WAIT_MS)); - - // Re-check which children are still alive and force kill them - childPids = this.getChildPids(currentPid); - for (const childPid of childPids) { - if (this.isProcessAlive(childPid)) { - try { - process.kill(childPid, 'SIGKILL'); - } catch { - // Process already terminated - } - } - } - } - - // Strategy 2: Kill the entire process group (catches any orphans we missed) - if (this.isProcessAlive(currentPid)) { - try { - process.kill(-currentPid, 'SIGTERM'); - await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS)); - if (this.isProcessAlive(currentPid)) { - process.kill(-currentPid, 'SIGKILL'); - } - } catch { - // Process group may not exist or already terminated - } - } - - // Strategy 3: Kill screen session by name - try { - execSync(`screen -S "${screen.screenName}" -X quit`, { - timeout: EXEC_TIMEOUT_MS - }); - } catch { - // Screen may already be dead - } - - // Strategy 4: Direct kill by PID as final fallback - if (this.isProcessAlive(currentPid)) { - try { - process.kill(currentPid, 'SIGKILL'); - } catch { - // Already dead - } - } - - // Verify all processes are dead (with timeout) - const allDead = await this.verifyProcessesDead(allPids, 2000); - if (!allDead) { - console.error(`[ScreenManager] Warning: Some processes may still be alive for screen ${screen.screenName}`); - } - - this.screens.delete(sessionId); - this.saveScreens(); - this.emit('sessionKilled', { sessionId }); - - return true; - } - - // Get all tracked screens - getScreens(): ScreenSession[] { - return Array.from(this.screens.values()); - } - - // Get screen by session ID - getScreen(sessionId: string): ScreenSession | undefined { - return this.screens.get(sessionId); - } - - // Update screen display name - updateScreenName(sessionId: string, name: string): boolean { - const screen = this.screens.get(sessionId); - if (!screen) { - return false; - } - screen.name = name; - this.saveScreens(); - return true; - } - - // Reconcile screens - find orphaned/dead screens AND discover unknown claudeman screens - async reconcileScreens(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }> { - // TEST MODE: Return all registered as alive, never discover real ones - if (IS_TEST_MODE) { - return { - alive: Array.from(this.screens.keys()), - dead: [], - discovered: [], - }; - } - - const alive: string[] = []; - const dead: string[] = []; - const discovered: string[] = []; - - // First, check known screens - for (const [sessionId, screen] of this.screens) { - const pid = this.getScreenPid(screen.screenName); - if (pid) { - alive.push(sessionId); - // Update PID if it changed - if (pid !== screen.pid) { - screen.pid = pid; - } - } else { - dead.push(sessionId); - this.screens.delete(sessionId); - this.emit('sessionDied', { sessionId }); - } - } - - // Second, discover unknown claudeman screens (prevents ghost screens) - try { - const output = execSync('screen -ls 2>/dev/null || true', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS - }); - // Match: "12345.claudeman-abc12345 (Detached)" or similar - // Reset lastIndex since we're reusing the global regex - SCREEN_PATTERN.lastIndex = 0; - let match; - while ((match = SCREEN_PATTERN.exec(output)) !== null) { - const pid = parseInt(match[1], 10); - const screenName = match[2]; - const sessionIdFragment = match[3]; - - // Check if this screen is already known - let isKnown = false; - for (const screen of this.screens.values()) { - if (screen.screenName === screenName) { - isKnown = true; - break; - } - } - - if (!isKnown) { - // Discovered an unknown claudeman screen - adopt it - const sessionId = `restored-${sessionIdFragment}`; - const screen: ScreenSession = { - sessionId, - screenName, - pid, - createdAt: Date.now(), - workingDir: process.cwd(), // Unknown, use current dir - mode: 'claude', // Assume claude mode - attached: false, - name: `Restored: ${screenName}` - }; - this.screens.set(sessionId, screen); - discovered.push(sessionId); - console.log(`[ScreenManager] Discovered unknown screen: ${screenName} (PID ${pid})`); - } - } - } catch (err) { - console.error('[ScreenManager] Failed to discover screens:', err); - } - - if (dead.length > 0 || discovered.length > 0) { - this.saveScreens(); - } - - return { alive, dead, discovered }; - } - - // Get process stats for a screen - async getProcessStats(sessionId: string): Promise { - const screen = this.screens.get(sessionId); - if (!screen) { - return null; - } - - try { - // Get memory and CPU usage using ps - const psOutput = execSync( - `ps -o rss=,pcpu= -p ${screen.pid} 2>/dev/null || echo "0 0"`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); - - const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0); - - // Count child processes - let childCount = 0; - try { - const childOutput = execSync( - `pgrep -P ${screen.pid} | wc -l`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); - childCount = parseInt(childOutput, 10) || 0; - } catch { - // No children or command failed - } - - return { - memoryMB: Math.round(rss / 1024 * 10) / 10, // KB to MB - cpuPercent: Math.round(cpu * 10) / 10, - childCount, - updatedAt: Date.now() - }; - } catch { - return null; - } - } - - // Get all screens with stats (batched for better performance) - // Now includes child process stats (the actual claude processes inside screens) - async getScreensWithStats(): Promise { - const screens = Array.from(this.screens.values()); - if (screens.length === 0) { - return []; - } - - const screenPids = screens.map(s => s.pid); - const statsMap = new Map(); - - try { - // Step 1: Get all descendant PIDs for each screen process - // This captures the claude process and any children it spawns - const descendantMap = new Map(); // screenPid -> [childPids] - - // Use pgrep to get all descendants recursively for each screen - const pgrepOutput = execSync( - `for p in ${screenPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); - - for (const line of pgrepOutput.split('\n')) { - const [pidStr, childrenStr] = line.split(':'); - const screenPid = parseInt(pidStr, 10); - if (!Number.isNaN(screenPid)) { - const children = (childrenStr || '') - .split(',') - .map(s => parseInt(s.trim(), 10)) - .filter(n => !Number.isNaN(n) && n > 0); - descendantMap.set(screenPid, children); - } - } - - // Step 2: Collect all PIDs we need stats for (screens + all their children) - const allPids = new Set(screenPids); - for (const children of descendantMap.values()) { - for (const child of children) { - allPids.add(child); - } - } - - // Step 3: Single ps call for ALL PIDs (screens + children) - const pidArray = Array.from(allPids); - if (pidArray.length > 0) { - const psOutput = execSync( - `ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); - - // Parse individual process stats - const processStats = new Map(); - for (const line of psOutput.split('\n')) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 3) { - const pid = parseInt(parts[0], 10); - const rss = parseFloat(parts[1]) || 0; - const cpu = parseFloat(parts[2]) || 0; - if (!Number.isNaN(pid)) { - processStats.set(pid, { rss, cpu }); - } - } - } - - // Step 4: Aggregate stats for each screen (screen + all descendants) - for (const screenPid of screenPids) { - const children = descendantMap.get(screenPid) || []; - const screenStats = processStats.get(screenPid) || { rss: 0, cpu: 0 }; - - // Sum up stats from all children - let totalRss = screenStats.rss; - let totalCpu = screenStats.cpu; - - for (const childPid of children) { - const childStats = processStats.get(childPid); - if (childStats) { - totalRss += childStats.rss; - totalCpu += childStats.cpu; - } - } - - statsMap.set(screenPid, { - memoryMB: Math.round(totalRss / 1024 * 10) / 10, - cpuPercent: Math.round(totalCpu * 10) / 10, - childCount: children.length, - updatedAt: Date.now() - }); - } - } - } catch { - // Fall back to individual queries if batch fails - const statsPromises = screens.map(screen => this.getProcessStats(screen.sessionId)); - const results = await Promise.allSettled(statsPromises); - return screens.map((screen, i) => ({ - ...screen, - stats: results[i].status === 'fulfilled' ? (results[i].value ?? undefined) : undefined - })); - } - - // Combine screens with their stats - return screens.map(screen => ({ - ...screen, - stats: statsMap.get(screen.pid) || undefined - })); - } - - // Start periodic stats collection - startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void { - if (this.statsInterval) { - clearInterval(this.statsInterval); - } - - this.statsInterval = setInterval(async () => { - try { - const screensWithStats = await this.getScreensWithStats(); - this.emit('statsUpdated', screensWithStats); - } catch (err) { - // Log but don't crash - stats collection is non-critical - console.error('[ScreenManager] Stats collection error:', err); - } - }, intervalMs); - } - - // Stop stats collection - stopStatsCollection(): void { - if (this.statsInterval) { - clearInterval(this.statsInterval); - this.statsInterval = null; - } - } - - /** - * Clean up resources. - * Call this on server shutdown. - */ - destroy(): void { - this.stopStatsCollection(); - } - - // Register a session as using screen (for when session creates its own screen) - registerScreen(screen: ScreenSession): void { - this.screens.set(screen.sessionId, screen); - this.saveScreens(); - } - - // Mark screen as attached/detached - setAttached(sessionId: string, attached: boolean): void { - const screen = this.screens.get(sessionId); - if (screen) { - screen.attached = attached; - this.saveScreens(); - } - } - - // Update respawn config for a screen session (persisted across restarts) - updateRespawnConfig(sessionId: string, config: PersistedRespawnConfig | undefined): void { - const screen = this.screens.get(sessionId); - if (screen) { - screen.respawnConfig = config; - this.saveScreens(); - } - } - - // Clear respawn config when respawn is stopped - clearRespawnConfig(sessionId: string): void { - const screen = this.screens.get(sessionId); - if (screen && screen.respawnConfig) { - delete screen.respawnConfig; - this.saveScreens(); - } - } - - // Update Ralph enabled state - updateRalphEnabled(sessionId: string, enabled: boolean): void { - const screen = this.screens.get(sessionId); - if (screen) { - screen.ralphEnabled = enabled; - this.saveScreens(); - } - } - - // Check if screen is available on the system - static isScreenAvailable(): boolean { - try { - execSync('which screen', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }); - return true; - } catch { - return false; - } - } - - // Send input directly to screen session using screen -X stuff - // This bypasses the attached PTY and sends input directly to the screen - async sendInput(sessionId: string, input: string): Promise { - if (IS_TEST_MODE) return true; - const screen = this.screens.get(sessionId); - if (!screen) { - console.error(`[ScreenManager] sendInput failed: no screen found for session ${sessionId}. Known screens: ${Array.from(this.screens.keys()).join(', ')}`); - return false; - } - - console.log(`[ScreenManager] sendInput to ${screen.screenName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`); - - // 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 - // Sending them together doesn't work with Ink/Claude CLI - const hasCarriageReturn = input.includes('\r'); - // Remove control characters and trim trailing whitespace to avoid spurious spaces - const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); - - // Escape the text part for shell using the helper function - const escapedText = shellEscape(textPart); - - // Send text first (if any) - if (escapedText) { - const textCmd = `screen -S "${screen.screenName}" -p 0 -X stuff "${escapedText}"`; - execSync(textCmd, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }); - } - - // Send carriage return separately (Enter key for Ink) - // Use synchronous delay to ensure screen processes the text first - if (hasCarriageReturn) { - // Delay to let screen process the text before sending Enter - // This prevents race conditions where Enter arrives before the text is processed - // 100ms is needed for reliability - screen's internal buffering can be slow - if (escapedText) { - // Use Atomics.wait for synchronous delay instead of shell command - const sharedBuffer = new SharedArrayBuffer(4); - const sharedArray = new Int32Array(sharedBuffer); - Atomics.wait(sharedArray, 0, 0, 100); - } - - const crCmd = `screen -S "${screen.screenName}" -p 0 -X stuff "$(printf '\\015')"`; - - // Try up to CR_MAX_ATTEMPTS times with increasing delays - let success = false; - for (let attempt = 1; attempt <= CR_MAX_ATTEMPTS && !success; attempt++) { - try { - execSync(crCmd, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }); - success = true; - } catch (crErr) { - console.warn(`[ScreenManager] Carriage return attempt ${attempt}/${CR_MAX_ATTEMPTS} failed`); - if (attempt < CR_MAX_ATTEMPTS) { - // Use synchronous sleep without shell command (security: avoid shell injection vectors) - const sleepMs = attempt * 100; // 100ms, 200ms, 300ms delays - const sharedBuffer = new SharedArrayBuffer(4); - const sharedArray = new Int32Array(sharedBuffer); - Atomics.wait(sharedArray, 0, 0, sleepMs); - } - } - } - - if (!success) { - console.error('[ScreenManager] All carriage return attempts failed'); - return false; - } - } - - return true; - } catch (err) { - console.error('[ScreenManager] Failed to send input:', err); - return false; - } - } - - // ======================================================================== - // TerminalMultiplexer interface adapter methods - // These delegate to existing Screen-specific methods, mapping between - // MuxSession (muxName) and ScreenSession (screenName). - // ======================================================================== - - private screenToMux(screen: ScreenSession): MuxSession { - return { - sessionId: screen.sessionId, - muxName: screen.screenName, - pid: screen.pid, - createdAt: screen.createdAt, - workingDir: screen.workingDir, - mode: screen.mode, - attached: screen.attached, - name: screen.name, - respawnConfig: screen.respawnConfig, - ralphEnabled: screen.ralphEnabled, - }; - } - - private screenWithStatsToMux(screen: ScreenSessionWithStats): MuxSessionWithStats { - return { - ...this.screenToMux(screen), - stats: screen.stats, - }; - } - - async createSession( - sessionId: string, - workingDir: string, - mode: 'claude' | 'shell', - name?: string, - niceConfig?: NiceConfig, - ): Promise { - const screen = await this.createScreen(sessionId, workingDir, mode, name, niceConfig); - return this.screenToMux(screen); - } - - async killSession(sessionId: string): Promise { - return this.killScreen(sessionId); - } - - getSessions(): MuxSession[] { - return this.getScreens().map(s => this.screenToMux(s)); - } - - getSession(sessionId: string): MuxSession | undefined { - const screen = this.getScreen(sessionId); - return screen ? this.screenToMux(screen) : undefined; - } - - async getSessionsWithStats(): Promise { - const screens = await this.getScreensWithStats(); - return screens.map(s => this.screenWithStatsToMux(s)); - } - - updateSessionName(sessionId: string, name: string): boolean { - return this.updateScreenName(sessionId, name); - } - - registerSession(session: MuxSession): void { - const screen: ScreenSession = { - sessionId: session.sessionId, - screenName: session.muxName, - pid: session.pid, - createdAt: session.createdAt, - workingDir: session.workingDir, - mode: session.mode, - attached: session.attached, - name: session.name, - respawnConfig: session.respawnConfig, - ralphEnabled: session.ralphEnabled, - }; - this.registerScreen(screen); - } - - async reconcileSessions(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }> { - return this.reconcileScreens(); - } - - getAttachCommand(): string { - return 'screen'; - } - - getAttachArgs(muxName: string): string[] { - return ['-x', muxName]; - } - - isAvailable(): boolean { - return ScreenManager.isScreenAvailable(); - } -} diff --git a/src/session.ts b/src/session.ts index 4520c477..32a3b5e9 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 { + async writeViaMux(data: string): Promise { 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 { + async stop(killMux: boolean = true): Promise { // 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 } diff --git a/src/templates/case-template.md b/src/templates/case-template.md index c2bf9818..681a9469 100644 --- a/src/templates/case-template.md +++ b/src/templates/case-template.md @@ -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 diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index cd215323..a4eb6980 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -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 { @@ -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 { const session = this.sessions.get(sessionId); diff --git a/src/types.ts b/src/types.ts index dbee6b59..16ca7606 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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-) */ - 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 ========== /** diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index c978c55e..5999267b 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -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 */ diff --git a/src/utils/nice-wrapper.ts b/src/utils/nice-wrapper.ts index 9655844f..a36d93b0 100644 --- a/src/utils/nice-wrapper.ts +++ b/src/utils/nice-wrapper.ts @@ -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 */ diff --git a/src/web/public/app.js b/src/web/public/app.js index e363612f..c319fbf8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 @@ -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 = '▼'; // 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 = '▲'; // 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 = '
No mux sessions
'; 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 += `
- ${screen.mode} + ${muxSession.mode}
-
${this.escapeHtml(screen.name || screen.screenName)}
+
${this.escapeHtml(muxSession.name || muxSession.muxName)}
${stats.memoryMB}MB ${stats.cpuPercent}% ${stats.childCount} children - PID: ${screen.pid} + PID: ${muxSession.pid}
- +
`; @@ -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'); } } diff --git a/src/web/public/index.html b/src/web/public/index.html index d6ee7b6f..43cda7c6 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -385,13 +385,13 @@ - +
Monitor
- + @@ -401,10 +401,10 @@
- Screen Sessions + Tmux Sessions
-
-
No screen sessions
+
+
No mux sessions