From 65f81667d0c90355eeb43d3f74e65e936980ac36 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 12 Feb 2026 10:29:57 +0100 Subject: [PATCH] chore: bump version to 0.1481 --- CLAUDE.md | 53 ++- package.json | 2 +- scripts/tmux-chooser.sh | 659 ++++++++++++++++++++++++++++ scripts/tmux-manager.sh | 505 ++++++++++++++++++++++ src/mux-factory.ts | 59 +++ src/mux-interface.ts | 168 ++++++++ src/plan-orchestrator.ts | 12 +- src/screen-manager.ts | 97 ++++- src/session.ts | 164 +++---- src/tmux-manager.ts | 888 ++++++++++++++++++++++++++++++++++++++ src/web/public/app.js | 6 + src/web/server.ts | 90 ++-- test/mux-factory.test.ts | 164 +++++++ test/setup.ts | 108 ++++- test/tmux-manager.test.ts | 387 +++++++++++++++++ 15 files changed, 3218 insertions(+), 144 deletions(-) create mode 100755 scripts/tmux-chooser.sh create mode 100755 scripts/tmux-manager.sh create mode 100644 src/mux-factory.ts create mode 100644 src/mux-interface.ts create mode 100644 src/tmux-manager.ts create mode 100644 test/mux-factory.test.ts create mode 100644 test/tmux-manager.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 98ccc331..88273e9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,13 +12,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co | E2E tests | `npm run test:e2e` | | Production | `npm run build && systemctl --user restart claudeman-web` | -## CRITICAL: Screen Session Safety +## CRITICAL: Session Safety -**You may be running inside a Claudeman-managed screen session.** Before killing ANY screen or Claude process: +**You may be running inside a Claudeman-managed tmux/screen session.** Before killing ANY tmux, screen, or Claude process: 1. Check: `echo $CLAUDEMAN_SCREEN` - if `1`, you're in a managed session -2. **NEVER** run `screen -X quit`, `pkill screen`, or `pkill claude` without confirming -3. Use the web UI or `./scripts/screen-manager.sh` instead of direct kill commands +2. **NEVER** run `tmux kill-session`, `screen -X quit`, `pkill tmux`, `pkill screen`, or `pkill claude` without confirming +3. Use the web UI or `./scripts/tmux-manager.sh` instead of direct kill commands ## COM Shorthand (Deployment) @@ -26,7 +26,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1480 (must match `package.json` for npm publish) +**Version**: 0.1481 (must match `package.json` for npm publish) ## Project Overview @@ -36,7 +36,7 @@ Claudeman is a Claude Code session manager with web interface and autonomous Ral **TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. Note: `src/tui` is excluded from compilation (legacy/deprecated code path). -**Requirements**: Node.js 18+, Claude CLI, GNU Screen +**Requirements**: Node.js 18+, Claude CLI, tmux (preferred) or GNU Screen (deprecated fallback via `CLAUDEMAN_MUX=screen`) ## Commands @@ -89,7 +89,10 @@ journalctl --user -u claudeman-web -f | File | Purpose | |------|---------| | `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` | -| `src/screen-manager.ts` | GNU screen persistence, ghost discovery | +| `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type | +| `src/mux-factory.ts` | Auto-detect tmux/screen, create multiplexer (`CLAUDEMAN_MUX` override) | +| `src/tmux-manager.ts` | tmux session management (preferred backend) | +| `src/screen-manager.ts` | GNU screen persistence, ghost discovery (deprecated fallback) | | `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 | @@ -100,8 +103,10 @@ journalctl --user -u claudeman-web -f | `src/task-queue.ts` | Priority queue for tasks with dependencies | | `src/task-tracker.ts` | Background task tracker for subagent detection | | `src/subagent-watcher.ts` | Monitors Claude Code's Task tool (background agents) | +| `src/team-watcher.ts` | Polls `~/.claude/teams/` for agent team activity; matches teams to sessions via `leadSessionId` | | `src/run-summary.ts` | Timeline events for "what happened while away" | -| `src/ai-idle-checker.ts` | AI-powered idle detection with `ai-checker-base.ts` | +| `src/ai-checker-base.ts` | Base class for AI-powered checkers (shared by idle + plan checkers) | +| `src/ai-idle-checker.ts` | AI-powered idle detection | | `src/ai-plan-checker.ts` | AI-powered plan completion checker | | `src/bash-tool-parser.ts` | Parses Claude's bash tool invocations from output | | `src/transcript-watcher.ts` | Watches Claude's transcript files for changes | @@ -109,6 +114,7 @@ journalctl --user -u claudeman-web -f | `src/image-watcher.ts` | Watches for image file creation (screenshots, etc.) | | `src/file-stream-manager.ts` | Manages `tail -f` processes for live log viewing | | `src/plan-orchestrator.ts` | Multi-agent plan generation with research and planning phases | +| `src/prompts/index.ts` | Barrel export for all agent prompts | | `src/prompts/*.ts` | Agent prompts (research-agent, code-reviewer, planner) | | `src/templates/claude-md.ts` | CLAUDE.md generation for new cases | | `src/cli.ts` | Command-line interface handlers | @@ -138,6 +144,7 @@ journalctl --user -u claudeman-web -f | `string-similarity.ts` | String matching utilities (fuzzy matching) | | `token-validation.ts` | Token count parsing and validation | | `regex-patterns.ts` | Shared regex patterns for parsing | +| `type-safety.ts` | `assertNever()` for exhaustive switch/case type checking | ### Data Flow @@ -148,7 +155,9 @@ journalctl --user -u claudeman-web -f ### Key Patterns -**Input to sessions**: Use `session.writeViaScreen()` for programmatic input (respawn, auto-compact). Text and Enter sent as separate `screen -X stuff` commands due to Ink's requirements. All prompts must be single-line. +**Input to sessions**: Use `session.writeViaScreen()` for programmatic input (respawn, auto-compact). With tmux, uses `send-keys -l` (literal text) + `send-keys Enter` — single command, no delay. With screen (deprecated), text and Enter sent as separate `screen -X stuff` commands with 100ms delay. All prompts must be single-line. + +**Terminal multiplexer abstraction**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts tmux vs screen. `createMultiplexer()` from `src/mux-factory.ts` auto-detects tmux (preferred) or falls back to screen. Set `CLAUDEMAN_MUX=screen` env var to force screen backend. **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. @@ -156,6 +165,8 @@ journalctl --user -u claudeman-web -f **Hook events**: Claude Code hooks trigger notifications via `/api/hook-event`. Key events: `permission_prompt` (tool approval needed), `elicitation_dialog` (Claude asking question), `idle_prompt` (waiting for input), `stop` (response complete). See `src/hooks-config.ts`. +**Agent Teams (experimental)**: `TeamWatcher` polls `~/.claude/teams/` for team configs and matches teams to sessions via `leadSessionId`. Teammates are in-process threads (not separate OS processes) and appear as standard subagents. RespawnController checks `TeamWatcher.hasActiveTeammates()` before triggering respawn. Enable via `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var in `settings.local.json`. See `agent-teams/` for full docs. + ## Adding Features - **API endpoint**: Types in `types.ts`, route in `server.ts:buildServer()`, use `createErrorResponse()`. Validate request bodies with Zod schemas. @@ -170,7 +181,8 @@ journalctl --user -u claudeman-web -f | File | Purpose | |------|---------| | `~/.claudeman/state.json` | Sessions, settings, tokens, respawn config | -| `~/.claudeman/screens.json` | Screen metadata for recovery | +| `~/.claudeman/mux-sessions.json` | Tmux session metadata for recovery | +| `~/.claudeman/screens.json` | Legacy screen metadata (auto-migrated to mux-sessions.json) | | `~/.claudeman/settings.json` | User preferences | ## Default Settings @@ -181,7 +193,7 @@ UI defaults are set in `src/web/public/app.js` using `??` fallbacks. To change d ## Testing -**Port allocation**: E2E tests use centralized ports in `test/e2e/e2e.config.ts`. Unit/integration tests pick unique ports manually. Search `const PORT =` or `TEST_PORT` in test files to find used ports before adding new tests. +**Port allocation**: E2E tests use centralized ports in `test/e2e/e2e.config.ts` (3183-3193). Unit/integration tests pick unique ports manually (team tests: 3150-3151). Search `const PORT =` or `TEST_PORT` in test files to find used ports before adding new tests. **E2E tests**: Use Playwright. Run `npx playwright install chromium` first. See `test/e2e/fixtures/` for helpers. E2E config (`test/e2e/e2e.config.ts`) provides ports (3183-3193), timeouts, and helpers. @@ -199,8 +211,8 @@ Respawn tests use MockSession to avoid spawning real Claude processes. See `test ## Debugging ```bash -screen -ls # List screens -screen -r # Attach (Ctrl+A D to detach) +tmux list-sessions # List tmux sessions +tmux attach-session -t # Attach (Ctrl+B D to detach) curl localhost:3000/api/sessions # Check sessions curl localhost:3000/api/status | jq # Full app state cat ~/.claudeman/state.json | jq # View persisted state @@ -214,12 +226,12 @@ curl localhost:3000/api/sessions/:id/run-summary | jq # Session timeline | Problem | Check | Fix | |---------|-------|-----| -| Session won't start | `screen -ls` for orphans | Kill orphaned screens, check Claude CLI installed | +| Session won't start | `tmux list-sessions` for orphans | Kill orphaned sessions, check Claude CLI installed | | Port 3000 in use | `lsof -i :3000` | Kill conflicting process or use `--port` flag | | SSE not connecting | Browser console for errors | Check CORS, ensure server running | | Respawn not triggering | Session settings → Respawn enabled? | Enable respawn, check idle timeout config | | Terminal blank on tab switch | Network tab for `/api/sessions/:id/buffer` | Check session exists, restart server | -| Tests failing on screen limits | `screen -ls \| wc -l` | Clean up test screens: `screen -ls \| grep test \| awk '{print $1}' \| xargs -I{} screen -X -S {} quit` | +| Tests failing on session limits | `tmux list-sessions \| wc -l` | Clean up test sessions: `tmux list-sessions \| grep test \| awk -F: '{print $1}' \| xargs -I{} tmux kill-session -t {}` | | State not persisting | `cat ~/.claudeman/state.json` | Check file permissions, disk space | ## Performance Constraints @@ -287,19 +299,26 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base | **Mobile/SSH access** | README.md (Claudeman Screens / `sc` command) | | **Plan orchestrator** | `src/plan-orchestrator.ts` file header | | **Agent prompts** | `src/prompts/` directory | +| **Agent Teams (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` | ## Scripts | Script | Purpose | |--------|---------| -| `scripts/screen-manager.sh` | Safe screen management (use instead of direct kill commands) | -| `scripts/screen-chooser.sh` | Claudeman Screens - mobile-friendly session picker (`sc` alias, see README for usage) | +| `scripts/tmux-manager.sh` | Safe tmux session management (use instead of direct kill commands) | +| `scripts/tmux-chooser.sh` | Mobile-friendly tmux session picker (`sc` alias) | +| `scripts/screen-manager.sh` | Legacy screen management (deprecated, use tmux-manager.sh) | +| `scripts/screen-chooser.sh` | Legacy screen session picker (deprecated, use tmux-chooser.sh) | | `scripts/monitor-respawn.sh` | Monitor respawn state machine in real-time | | `scripts/postinstall.js` | npm postinstall hook for setup | | `scripts/data-generator.sh` | Generate test data for development | | `scripts/test-tail-links.sh` | Test clickable file links in tail output | | `scripts/capture-subagent-screenshots.mjs` | Capture subagent screenshots/GIFs for README (uses real Claude sessions) | | `scripts/mobile-screenshot.mjs` | Capture mobile UI screenshots | +| `scripts/ralph-wizard-start.mjs` | Automate Ralph Loop startup via headless browser | +| `scripts/ralph-wizard-prod.mjs` | Production Ralph wizard with HTTPS support | +| `scripts/watch-subagents.ts` | Real-time subagent transcript watcher (list, follow by session/agent ID) | +| `scripts/claudeman-web.service` | systemd service file for production deployment | ## Memory Leak Prevention diff --git a/package.json b/package.json index 28f6aed1..23354861 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1480", + "version": "0.1481", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/scripts/tmux-chooser.sh b/scripts/tmux-chooser.sh new file mode 100755 index 00000000..2d2fca06 --- /dev/null +++ b/scripts/tmux-chooser.sh @@ -0,0 +1,659 @@ +#!/bin/bash +# ============================================================================ +# Claudeman Sessions - Mobile-friendly Tmux 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: +# tmux-chooser # Interactive chooser +# tmux-chooser 1 # Quick attach to session 1 +# tmux-chooser -l # List only (non-interactive) +# tmux-chooser -h # Help +# +# Alias: alias sc='tmux-chooser' +# Then: sc (interactive) +# sc 2 (attach session 2) +# +# ============================================================================ + +set -e + +# ============================================================================ +# Configuration +# ============================================================================ + +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 +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_icons() { + if [[ "$TERM_PROGRAM" == "iTerm"* ]] || \ + [[ "$TERM" == "xterm-kitty" ]] || \ + [[ -n "$WEZTERM_PANE" ]] || \ + [[ "$LC_TERMINAL" == "iTerm2" ]]; then + ICON_SESSION="󰆍" + ICON_ATTACHED="●" + ICON_DETACHED="○" + ICON_UNKNOWN="◌" + else + ICON_SESSION="[T]" + 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' + +# ============================================================================ +# Utilities +# ============================================================================ + +truncate() { + local str="$1" + local max="$2" + local len=${#str} + + if [ "$len" -le "$max" ]; then + echo "$str" + return + fi + + if [[ "$str" == *"/"* ]]; then + echo "..${str: -$((max-2))}" + else + echo "${str:0:$((max-1))}…" + fi +} + +find_full_session_id() { + local short_id="$1" + + 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 + + if [ -f "$CLAUDEMAN_SESSIONS" ]; then + local full_id + full_id=$(jq -r --arg short "$short_id" ' + .[] | select(.sessionId | startswith($short)) | .sessionId + ' "$CLAUDEMAN_SESSIONS" 2>/dev/null | head -1) + if [ -n "$full_id" ]; then + echo "$full_id" + return + fi + fi + + echo "$short_id" +} + +get_session_name() { + local session_id="$1" + local name="" + local workdir="" + + if [ -f "$CLAUDEMAN_SESSIONS" ]; then + local result + result=$(jq -r --arg id "$session_id" ' + .[] | select(.sessionId | startswith($id)) | "\(.name // "")\t\(.workingDir // "")" + ' "$CLAUDEMAN_SESSIONS" 2>/dev/null | head -1) + if [ -n "$result" ]; then + name="${result%% *}" + workdir="${result#* }" + fi + fi + + 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 [ -n "$name" ]; then + echo "$name" + return + fi + + if [ -n "$workdir" ]; then + echo "${workdir##*/}" + return + fi + + echo "${session_id:0:8}" +} + +get_working_dir() { + local session_id="$1" + + if [ -f "$CLAUDEMAN_SESSIONS" ]; then + local dir + dir=$(jq -r --arg id "$session_id" ' + .[] | select(.sessionId | startswith($id)) | .workingDir // empty + ' "$CLAUDEMAN_SESSIONS" 2>/dev/null | head -1) + if [ -n "$dir" ] && [ "$dir" != "null" ]; then + echo "${dir/#$HOME/~}" + return + fi + fi + + 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_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() { + local session_id="$1" + + if [ -f "$CLAUDEMAN_SESSIONS" ]; then + local respawn_enabled + respawn_enabled=$(jq -r --arg id "$session_id" ' + .[] | select(.sessionId | startswith($id)) | .respawnConfig.enabled // false + ' "$CLAUDEMAN_SESSIONS" 2>/dev/null | head -1) + + if [ "$respawn_enabled" = "true" ]; then + echo "R" + return + fi + fi + echo "" +} + +check_deps() { + if ! command -v jq &>/dev/null; then + echo -e "${YELLOW}Note: Install jq for session names${R}" + echo "" + fi +} + +# ============================================================================ +# Tmux Session Parser +# ============================================================================ + +declare -a SESSION_PIDS +declare -a MUX_NAMES +declare -a SESSION_STATES +declare -a SESSION_IDS +declare -a DISPLAY_NAMES +declare -a WORKING_DIRS +declare -a TOKEN_COUNTS +declare -a RESPAWN_STATUS + +parse_sessions() { + SESSION_PIDS=() + MUX_NAMES=() + SESSION_STATES=() + SESSION_IDS=() + DISPLAY_NAMES=() + WORKING_DIRS=() + TOKEN_COUNTS=() + RESPAWN_STATUS=() + + local i=0 + + # Parse tmux list-sessions output + while IFS= read -r line; do + local session_name="${line%%:*}" + + # Only show claudeman sessions + if [[ "$session_name" != claudeman-* ]]; then + continue + fi + + # Check if attached + local state="Detached" + if [[ "$line" == *"(attached)"* ]]; then + state="Attached" + fi + + # Get PID from tmux + local pid + pid=$(tmux display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0") + + SESSION_PIDS+=("$pid") + MUX_NAMES+=("$session_name") + SESSION_STATES+=("$state") + + # Extract session ID from claudeman session name + local session_id="" + local cm_regex='^claudeman-(.+)$' + if [[ "$session_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+=("$session_name") + WORKING_DIRS+=("") + TOKEN_COUNTS+=("") + RESPAWN_STATUS+=("") + fi + + i=$((i + 1)) + done < <(tmux list-sessions 2>/dev/null || true) +} + +# ============================================================================ +# Display Functions +# ============================================================================ + +clear_screen() { + printf '\033[2J\033[H' +} + +print_header() { + local count=${#SESSION_PIDS[@]} + echo -e "${B}${CYAN}Claudeman Sessions${R} ${D}($count)${R}" + echo -e "${D}$(printf '%.0s─' {1..32})${R}" +} + +print_entry() { + local idx="$1" + local num=$((idx + 1)) + local name="${DISPLAY_NAMES[$idx]}" + local state="${SESSION_STATES[$idx]}" + local dir="${WORKING_DIRS[$idx]}" + local tokens="${TOKEN_COUNTS[$idx]}" + local respawn="${RESPAWN_STATUS[$idx]}" + + 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) + + 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 + + local num_str="${B}${WHITE}${num})${R}" + local name_str="${B}${WHITE}${name}${R}" + local status_str="${status_color}${status_icon}${R}" + + local respawn_str="" + if [ -n "$respawn" ]; then + respawn_str=" ${GREEN}${respawn}${R}" + fi + + 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}" + + if [ -n "$dir" ]; then + dir=$(truncate "$dir" $((MAX_NAME_LEN - 2))) + echo -e " ${D}${dir}${R}" + fi +} + +print_footer() { + local page="$1" + local total_pages="$2" + + echo "" + echo -e "${D}────────────────────────────────${R}" + + 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 + + echo -e " ${GRAY}[${WHITE}1-9${GRAY}]attach [${WHITE}r${GRAY}]efresh [${WHITE}q${GRAY}]uit${R}" +} + +print_no_sessions() { + clear_screen + echo -e "${B}${CYAN}Claudeman Sessions${R}" + echo -e "${D}$(printf '%.0s─' {1..32})${R}" + echo "" + echo -e " ${YELLOW}No tmux 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_sessions + + local count=${#SESSION_PIDS[@]} + + if [ "$count" -eq 0 ]; then + print_no_sessions + return + fi + + local total_pages=$(( (count + PAGE_SIZE - 1) / PAGE_SIZE )) + + 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_session() { + local idx="$1" + local mux_name="${MUX_NAMES[$idx]}" + + if [ -z "$mux_name" ]; then + return 1 + fi + + clear_screen + echo -e "${GREEN}Attaching to ${B}${DISPLAY_NAMES[$idx]}${R}${GREEN}...${R}" + echo -e "${D}(Ctrl+B D to detach)${R}" + sleep 0.3 + + tmux attach-session -t "$mux_name" + + return 0 +} + +# ============================================================================ +# Input Handler +# ============================================================================ + +handle_input() { + local key="$1" + local count=${#SESSION_PIDS[@]} + local total_pages=$(( (count + PAGE_SIZE - 1) / PAGE_SIZE )) + + case "$key" in + [1-9]) + local idx=$((key - 1)) + if [ "$idx" -lt "$count" ]; then + attach_session "$idx" + return 0 + fi + ;; + + $'\e') + read -rsn2 -t 0.1 seq 2>/dev/null || true + case "$seq" in + '[A'|'[D') + if [ "$total_pages" -gt 1 ]; then + current_page=$(( (current_page - 1 + total_pages) % total_pages )) + fi + ;; + '[B'|'[C') + if [ "$total_pages" -gt 1 ]; then + current_page=$(( (current_page + 1) % total_pages )) + fi + ;; + esac + ;; + + 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 + ;; + + r|R) + ;; + + q|Q) + clear_screen + exit 0 + ;; + + '') + if [ "$count" -eq 1 ]; then + attach_session 0 + return 0 + fi + ;; + esac + + return 0 +} + +# ============================================================================ +# List Mode +# ============================================================================ + +list_mode() { + parse_sessions + local count=${#SESSION_PIDS[@]} + + if [ "$count" -eq 0 ]; then + echo "No tmux sessions" + exit 0 + fi + + for ((i = 0; i < count; i++)); do + local num=$((i + 1)) + local name="${DISPLAY_NAMES[$i]}" + local state="${SESSION_STATES[$i]}" + local respawn="${RESPAWN_STATUS[$i]}" + local indicator="-" + [[ "$state" == *"Attached"* ]] && indicator="*" + [ -n "$respawn" ] && indicator="${indicator}R" + + echo "$num) $name [$indicator]" + done +} + +# ============================================================================ +# Quick Attach +# ============================================================================ + +quick_attach() { + local num="$1" + parse_sessions + + local count=${#SESSION_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_session "$idx" +} + +# ============================================================================ +# Help +# ============================================================================ + +show_help() { + cat << 'EOF' +Claudeman Sessions - Mobile-friendly Tmux 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 tmux: Ctrl+B D + - Session names from Claudeman state + - Optimized for Termius/iPhone + +EOF +} + +# ============================================================================ +# Main +# ============================================================================ + +main() { + case "${1:-}" in + -h|--help) + show_help + exit 0 + ;; + -l|--list) + list_mode + exit 0 + ;; + [1-9]|[1-9][0-9]) + quick_attach "$1" + exit $? + ;; + esac + + check_deps + + render + + while true; do + 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 + render + done +} + +trap 'clear_screen; exit 0' INT + +main "$@" diff --git a/scripts/tmux-manager.sh b/scripts/tmux-manager.sh new file mode 100755 index 00000000..b4ee1f24 --- /dev/null +++ b/scripts/tmux-manager.sh @@ -0,0 +1,505 @@ +#!/bin/bash +# +# Claudeman Tmux Session Manager +# Interactive tool with arrow key navigation +# Reads from ~/.claudeman/mux-sessions.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/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="" +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 tmux &> /dev/null; then + echo -e "${RED}Error: 'tmux' is required. Install with: sudo apt install tmux${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 "$SESSIONS_FILE" ]]; then + CACHED_JSON=$(cat "$SESSIONS_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 + # Support both muxName (tmux) and screenName (legacy) fields + if [[ "$field" == "muxName" ]]; then + local val=$(echo "$CACHED_JSON" | jq -r ".[$index].muxName // .[$index].screenName // \"unknown\"") + echo "$val" + else + echo "$CACHED_JSON" | jq -r ".[$index].$field // \"unknown\"" + fi +} + +# 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 tmux session alive (cached per draw cycle) +declare -A ALIVE_CACHE +check_alive() { + local mux_name=$1 + if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then + if tmux has-session -t "$mux_name" 2>/dev/null; then + ALIVE_CACHE[$mux_name]=1 + else + ALIVE_CACHE[$mux_name]=0 + fi + fi + [[ "${ALIVE_CACHE[$mux_name]}" -eq 1 ]] +} + +clear_alive_cache() { + ALIVE_CACHE=() +} + +# Kill session by index +kill_session() { + local idx=$1 + local mux_name=$(get_session_field $idx "muxName") + local pid=$(get_session_field $idx "pid") + + pkill -TERM -P $pid 2>/dev/null + kill -TERM -$pid 2>/dev/null + tmux kill-session -t "$mux_name" 2>/dev/null + kill -KILL $pid 2>/dev/null + + # Remove from JSON + CACHED_JSON=$(echo "$CACHED_JSON" | jq "del(.[$idx])") + echo "$CACHED_JSON" > "$SESSIONS_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 Tmux 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 mux_name=$(get_session_field $i "muxName") + 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 "$mux_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: $SESSIONS_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}Tmux:${NC} $mux_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 "$mux_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 mux_name=$(get_session_field $selected "muxName") + if check_alive "$mux_name"; then + tput cnorm + clear + echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}" + sleep 0.3 + tmux attach-session -t "$mux_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 ($SESSIONS_FILE):" + echo "" + printf "%-4s %-22s %-14s %-10s %-8s\n" "#" "NAME" "RUNNING" "STATUS" "MODE" + echo "─────────────────────────────────────────────────────────────────" + + for ((i=0; i"; exit 1; } + force_refresh + local mux_name=$(get_session_field $(($2-1)) "muxName") + check_alive "$mux_name" && tmux attach-session -t "$mux_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/src/mux-factory.ts b/src/mux-factory.ts new file mode 100644 index 00000000..4429772b --- /dev/null +++ b/src/mux-factory.ts @@ -0,0 +1,59 @@ +/** + * @fileoverview Factory for creating the appropriate terminal multiplexer. + * + * Auto-detects tmux vs GNU Screen at startup. Prefers tmux. + * Set `CLAUDEMAN_MUX=screen` to force GNU Screen backend. + * + * @module mux-factory + */ + +import type { TerminalMultiplexer } from './mux-interface.js'; +import { ScreenManager } from './screen-manager.js'; +import { TmuxManager } from './tmux-manager.js'; + +/** + * Create a TerminalMultiplexer instance based on availability and preference. + * + * Detection order: + * 1. If `forced` parameter is set, use that backend + * 2. If `CLAUDEMAN_MUX` env var is set, use that backend + * 3. If tmux is available, use tmux (preferred) + * 4. If screen is available, use screen (deprecated fallback) + * 5. Throw error with install instructions + */ +export function createMultiplexer(forced?: 'tmux' | 'screen'): TerminalMultiplexer { + const preference = forced || process.env.CLAUDEMAN_MUX; + + if (preference === 'screen') { + if (!ScreenManager.isScreenAvailable()) { + throw new Error('GNU Screen requested via CLAUDEMAN_MUX=screen but not found. Install: sudo apt install screen'); + } + console.log('[MuxFactory] Using GNU Screen backend (forced via CLAUDEMAN_MUX)'); + return new ScreenManager(); + } + + if (preference === 'tmux') { + if (!TmuxManager.isTmuxAvailable()) { + throw new Error('tmux requested via CLAUDEMAN_MUX=tmux but not found. Install: sudo apt install tmux'); + } + console.log('[MuxFactory] Using tmux backend (forced via CLAUDEMAN_MUX)'); + return new TmuxManager(); + } + + // Auto-detect: prefer tmux + if (TmuxManager.isTmuxAvailable()) { + console.log('[MuxFactory] Using tmux backend (auto-detected)'); + return new TmuxManager(); + } + + if (ScreenManager.isScreenAvailable()) { + console.warn('[MuxFactory] Using GNU Screen backend (tmux not found). Consider installing tmux for better performance: sudo apt install tmux'); + return new ScreenManager(); + } + + throw new Error( + 'No terminal multiplexer found. Install one of:\n' + + ' - tmux (recommended): sudo apt install tmux\n' + + ' - GNU Screen (legacy): sudo apt install screen' + ); +} diff --git a/src/mux-interface.ts b/src/mux-interface.ts new file mode 100644 index 00000000..ab3fc4bc --- /dev/null +++ b/src/mux-interface.ts @@ -0,0 +1,168 @@ +/** + * @fileoverview Terminal multiplexer abstraction layer. + * + * Defines the TerminalMultiplexer interface that both ScreenManager (GNU Screen) + * and TmuxManager (tmux) implement. This allows the rest of the codebase to work + * with either backend transparently. + * + * The MuxSession type is the backend-agnostic equivalent of ScreenSession. + * + * @module mux-interface + */ + +import type { EventEmitter } from 'node:events'; +import type { ProcessStats, PersistedRespawnConfig, NiceConfig } from './types.js'; + +/** + * Backend-agnostic multiplexer session. + * Equivalent to ScreenSession but uses `muxName` instead of `screenName`. + */ +export interface MuxSession { + /** Claudeman session ID */ + sessionId: string; + /** Multiplexer session name (e.g., "claudeman-abc12345") */ + muxName: string; + /** Process PID */ + pid: number; + /** Timestamp when created */ + createdAt: number; + /** Working directory */ + workingDir: string; + /** Session mode: claude or shell */ + mode: 'claude' | 'shell'; + /** Whether webserver is attached to this session */ + attached: boolean; + /** Session display name (tab name) */ + name?: string; + /** Persisted respawn controller configuration (restored on server restart) */ + respawnConfig?: PersistedRespawnConfig; + /** Whether Ralph / Todo tracking is enabled */ + ralphEnabled?: boolean; +} + +/** + * MuxSession with optional process resource statistics. + */ +export interface MuxSessionWithStats extends MuxSession { + /** Optional resource statistics */ + stats?: ProcessStats; +} + +/** + * Terminal multiplexer interface. + * + * Both ScreenManager and TmuxManager implement this interface, + * allowing the rest of the codebase to work with either backend. + * + * Events emitted: + * - `sessionCreated` (session: MuxSession) - New session created + * - `sessionKilled` (data: { sessionId: string }) - Session terminated + * - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly + * - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed + */ +export interface TerminalMultiplexer extends EventEmitter { + /** Which backend this instance uses */ + readonly backend: 'tmux' | 'screen'; + + // ========== Lifecycle ========== + + /** + * Create a new multiplexer session. + * The session runs the appropriate command (claude or shell) in detached mode. + */ + createSession( + sessionId: string, + workingDir: string, + mode: 'claude' | 'shell', + name?: string, + niceConfig?: NiceConfig, + ): Promise; + + /** + * Kill a session and all its child processes. + * Uses a multi-strategy approach (children → process group → mux kill → SIGKILL). + */ + killSession(sessionId: string): Promise; + + /** Clean up resources (stop stats collection, etc.) */ + destroy(): void; + + // ========== Queries ========== + + /** Get all tracked sessions */ + getSessions(): MuxSession[]; + + /** Get a session by Claudeman session ID */ + getSession(sessionId: string): MuxSession | undefined; + + /** Get all sessions with process resource statistics */ + getSessionsWithStats(): Promise; + + /** Get process stats for a single session */ + getProcessStats(sessionId: string): Promise; + + // ========== Input ========== + + /** + * Send input to a session. + * tmux: `send-keys -l 'text' Enter` (single command, no delay) + * screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries) + */ + sendInput(sessionId: string, input: string): boolean; + + // ========== Metadata ========== + + /** Update the display name of a session */ + updateSessionName(sessionId: string, name: string): boolean; + + /** Mark session as attached/detached */ + setAttached(sessionId: string, attached: boolean): void; + + /** Register an externally-created session for tracking */ + registerSession(session: MuxSession): void; + + /** Update persisted respawn config for a session */ + updateRespawnConfig(sessionId: string, config: PersistedRespawnConfig | undefined): void; + + /** Clear respawn config when respawn is stopped */ + clearRespawnConfig(sessionId: string): void; + + /** Update Ralph enabled state for a session */ + updateRalphEnabled(sessionId: string, enabled: boolean): void; + + // ========== Discovery ========== + + /** + * Reconcile tracked sessions with actual running sessions. + * Finds dead sessions and discovers unknown ones. + */ + reconcileSessions(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }>; + + // ========== Stats Collection ========== + + /** Start periodic process stats collection */ + startStatsCollection(intervalMs?: number): void; + + /** Stop periodic process stats collection */ + stopStatsCollection(): void; + + // ========== PTY Attachment ========== + + /** + * Get the command to spawn for attaching to a session. + * Returns 'tmux' or 'screen'. + */ + getAttachCommand(): string; + + /** + * Get the arguments for attaching to a session by mux name. + * tmux: ['attach-session', '-t', muxName] + * screen: ['-x', muxName] + */ + getAttachArgs(muxName: string): string[]; + + // ========== Availability ========== + + /** Check if the multiplexer binary is available on the system */ + isAvailable(): boolean; +} diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index 1ec8b3a1..e9859db8 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -16,7 +16,7 @@ */ import { Session } from './session.js'; -import { ScreenManager } from './screen-manager.js'; +import type { TerminalMultiplexer } from './mux-interface.js'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { @@ -157,15 +157,15 @@ const MODEL = 'opus'; // ============================================================================ export class PlanOrchestrator { - private screenManager: ScreenManager; + private mux: TerminalMultiplexer; private workingDir: string; private outputDir?: string; private runningSessions: Set = new Set(); private cancelled = false; private taskDescription = ''; - constructor(screenManager: ScreenManager, workingDir: string = process.cwd(), outputDir?: string) { - this.screenManager = screenManager; + constructor(mux: TerminalMultiplexer, workingDir: string = process.cwd(), outputDir?: string) { + this.mux = mux; this.workingDir = workingDir; this.outputDir = outputDir; } @@ -390,7 +390,7 @@ export class PlanOrchestrator { const session = new Session({ workingDir: this.workingDir, - screenManager: this.screenManager, + screenManager: this.mux, useScreen: false, mode: 'claude', }); @@ -472,7 +472,7 @@ export class PlanOrchestrator { const session = new Session({ workingDir: this.workingDir, - screenManager: this.screenManager, + screenManager: this.mux, useScreen: false, mode: 'claude', }); diff --git a/src/screen-manager.ts b/src/screen-manager.ts index 275302fc..e27463c1 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -20,6 +20,7 @@ import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js'; +import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js'; // ============================================================================ // Claude CLI PATH Resolution @@ -181,7 +182,8 @@ function shellEscape(str: string): string { * @fires ScreenManager#screenCreated - New screen session created * @fires ScreenManager#screenKilled - Screen session terminated */ -export class ScreenManager extends EventEmitter { +export class ScreenManager extends EventEmitter implements TerminalMultiplexer { + readonly backend = 'screen' as const; private screens: Map = new Map(); private statsInterval: NodeJS.Timeout | null = null; @@ -903,4 +905,97 @@ export class ScreenManager extends EventEmitter { return false; } } + + // ======================================================================== + // TerminalMultiplexer interface adapter methods + // These delegate to existing Screen-specific methods, mapping between + // MuxSession (muxName) and ScreenSession (screenName). + // ======================================================================== + + private screenToMux(screen: ScreenSession): MuxSession { + return { + sessionId: screen.sessionId, + muxName: screen.screenName, + pid: screen.pid, + createdAt: screen.createdAt, + workingDir: screen.workingDir, + mode: screen.mode, + attached: screen.attached, + name: screen.name, + respawnConfig: screen.respawnConfig, + ralphEnabled: screen.ralphEnabled, + }; + } + + private screenWithStatsToMux(screen: ScreenSessionWithStats): MuxSessionWithStats { + return { + ...this.screenToMux(screen), + stats: screen.stats, + }; + } + + async createSession( + sessionId: string, + workingDir: string, + mode: 'claude' | 'shell', + name?: string, + niceConfig?: NiceConfig, + ): Promise { + 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 41d6f7e7..97571d4b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -21,11 +21,11 @@ import { existsSync } from 'node:fs'; import { dirname } from 'node:path'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; -import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js'; +import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js'; +import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; -import { ScreenManager } from './screen-manager.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js'; import { LRUMap } from './utils/lru-map.js'; import { @@ -361,10 +361,10 @@ export class Session extends EventEmitter { private _promptCheckTimeout: NodeJS.Timeout | null = null; private _shellIdleTimer: NodeJS.Timeout | null = null; - // Screen session support - private _screenManager: ScreenManager | null = null; - private _screenSession: ScreenSession | null = null; - private _useScreen: boolean = false; + // Multiplexer session support (tmux or GNU Screen) + private _mux: TerminalMultiplexer | null = null; + private _muxSession: MuxSession | null = null; + private _useMux: boolean = false; // Flag to prevent new timers after session is stopped private _isStopped: boolean = false; @@ -415,9 +415,18 @@ export class Session extends EventEmitter { workingDir: string; mode?: SessionMode; name?: string; - screenManager?: ScreenManager; + /** Terminal multiplexer instance (tmux or screen) */ + mux?: TerminalMultiplexer; + /** Whether to use multiplexer wrapping */ + useMux?: boolean; + /** Existing mux session for restored sessions */ + muxSession?: MuxSession; + /** @deprecated Use `mux` instead */ + screenManager?: TerminalMultiplexer; + /** @deprecated Use `useMux` instead */ useScreen?: boolean; - screenSession?: ScreenSession; // For restored sessions - pass the existing screen + /** @deprecated Use `muxSession` instead */ + screenSession?: MuxSession; niceConfig?: NiceConfig; // Nice prioritying configuration }) { super(); @@ -427,9 +436,10 @@ export class Session extends EventEmitter { this.mode = config.mode || 'claude'; this._name = config.name || ''; this._lastActivityAt = this.createdAt; - this._screenManager = config.screenManager || null; - this._useScreen = config.useScreen ?? (this._screenManager !== null && ScreenManager.isScreenAvailable()); - this._screenSession = config.screenSession || null; // Use existing screen if provided + // Support both new (mux) and deprecated (screenManager) parameter names + this._mux = config.mux || config.screenManager || null; + this._useMux = config.useMux ?? config.useScreen ?? (this._mux !== null && this._mux.isAvailable()); + this._muxSession = config.muxSession || config.screenSession || null; // Apply Nice priority configuration if provided if (config.niceConfig) { @@ -868,29 +878,30 @@ export class Session extends EventEmitter { this._lineBuffer = ''; this._lastActivityAt = Date.now(); - console.log('[Session] Starting interactive Claude session' + (this._useScreen ? ' (with screen)' : '')); + console.log('[Session] Starting interactive Claude session' + (this._useMux ? ` (with ${this._mux!.backend})` : '')); - // If screen wrapping is enabled, create or attach to a screen session - if (this._useScreen && this._screenManager) { + // If mux wrapping is enabled, create or attach to a mux session + if (this._useMux && this._mux) { try { - // Check if we already have a screen session (restored session) - const isRestoredSession = this._screenSession !== null; + // Check if we already have a mux session (restored session) + const isRestoredSession = this._muxSession !== null; if (isRestoredSession) { - console.log('[Session] Attaching to existing screen session:', this._screenSession!.screenName); + console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); } else { - // Create a new screen session - this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'claude', this._name, this._niceConfig); - console.log('[Session] Created screen session:', this._screenSession.screenName); + // Create a new mux session + this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'claude', this._name, this._niceConfig); + console.log('[Session] Created mux session:', this._muxSession.muxName); - // Wait a moment for screen to fully start + // Wait a moment for mux to fully start await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS)); } - // Attach to the screen session via PTY + // Attach to the mux session via PTY try { - this.ptyProcess = pty.spawn('screen', [ - '-x', this._screenSession!.screenName - ], { + this.ptyProcess = pty.spawn( + this._mux.getAttachCommand(), + this._mux.getAttachArgs(this._muxSession!.muxName), + { name: 'xterm-256color', cols: 120, rows: 40, @@ -899,7 +910,7 @@ export class Session extends EventEmitter { }); // Set claudeSessionId immediately since we passed --session-id to Claude - // The screen-manager passes --session-id ${sessionId} to Claude + // The mux manager passes --session-id ${sessionId} to Claude this._claudeSessionId = this.id; } catch (spawnErr) { console.error('[Session] Failed to spawn PTY for screen attachment:', spawnErr); @@ -942,13 +953,13 @@ export class Session extends EventEmitter { }, 5000); } } catch (err) { - console.error('[Session] Failed to create screen session, falling back to direct PTY:', err); - this._useScreen = false; - this._screenSession = null; + console.error('[Session] Failed to create mux session, falling back to direct PTY:', err); + this._useMux = false; + this._muxSession = null; } } - // Fallback to direct PTY if screen is not used + // Fallback to direct PTY if mux is not used if (!this.ptyProcess) { try { // Pass --session-id to use the SAME ID as the Claudeman session @@ -1084,9 +1095,9 @@ export class Session extends EventEmitter { clearTimeout(this._promptCheckTimeout); this._promptCheckTimeout = null; } - // If using screen, mark the screen as detached but don't kill it - if (this._screenSession && this._screenManager) { - this._screenManager.setAttached(this.id, false); + // If using mux, mark the session as detached but don't kill it + if (this._muxSession && this._mux) { + this._mux.setAttached(this.id, false); } this.emit('exit', exitCode); }); @@ -1122,29 +1133,30 @@ export class Session extends EventEmitter { // Use user's default shell or bash const shell = process.env.SHELL || '/bin/bash'; - console.log('[Session] Starting shell session with:', shell + (this._useScreen ? ' (with screen)' : '')); + console.log('[Session] Starting shell session with:', shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')); - // If screen wrapping is enabled, create or attach to a screen session - if (this._useScreen && this._screenManager) { + // If mux wrapping is enabled, create or attach to a mux session + if (this._useMux && this._mux) { try { - // Check if we already have a screen session (restored session) - const isRestoredSession = this._screenSession !== null; + // Check if we already have a mux session (restored session) + const isRestoredSession = this._muxSession !== null; if (isRestoredSession) { - console.log('[Session] Attaching to existing screen session:', this._screenSession!.screenName); + console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); } else { - // Create a new screen session - this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'shell', this._name, this._niceConfig); - console.log('[Session] Created screen session:', this._screenSession.screenName); + // Create a new mux session + this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'shell', this._name, this._niceConfig); + console.log('[Session] Created mux session:', this._muxSession.muxName); - // Wait a moment for screen to fully start + // Wait a moment for mux to fully start await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS)); } - // Attach to the screen session via PTY + // Attach to the mux session via PTY try { - this.ptyProcess = pty.spawn('screen', [ - '-x', this._screenSession!.screenName - ], { + this.ptyProcess = pty.spawn( + this._mux.getAttachCommand(), + this._mux.getAttachArgs(this._muxSession!.muxName), + { name: 'xterm-256color', cols: 120, rows: 40, @@ -1152,13 +1164,13 @@ export class Session extends EventEmitter { env: { ...process.env, TERM: 'xterm-256color' }, }); } catch (spawnErr) { - console.error('[Session] Failed to spawn PTY for shell screen attachment:', spawnErr); - this.emit('error', `Failed to attach to screen: ${spawnErr}`); + console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr); + this.emit('error', `Failed to attach to mux session: ${spawnErr}`); throw spawnErr; } - // For NEW screens: clear by sending 'clear' command to the shell - // For RESTORED screens: don't clear - we want to see the existing output + // For NEW sessions: clear by sending 'clear' command to the shell + // For RESTORED sessions: don't clear - we want to see the existing output if (!isRestoredSession) { setTimeout(() => { if (this.ptyProcess) { @@ -1168,13 +1180,13 @@ export class Session extends EventEmitter { }, 100); } } catch (err) { - console.error('[Session] Failed to create screen session, falling back to direct PTY:', err); - this._useScreen = false; - this._screenSession = null; + console.error('[Session] Failed to create mux session, falling back to direct PTY:', err); + this._useMux = false; + this._muxSession = null; } } - // Fallback to direct PTY if screen is not used + // Fallback to direct PTY if mux is not used if (!this.ptyProcess) { try { this.ptyProcess = pty.spawn(shell, [], { @@ -1228,9 +1240,9 @@ export class Session extends EventEmitter { clearTimeout(this.activityTimeout); this.activityTimeout = null; } - // If using screen, mark the screen as detached but don't kill it - if (this._screenSession && this._screenManager) { - this._screenManager.setAttached(this.id, false); + // If using mux, mark the session as detached but don't kill it + if (this._muxSession && this._mux) { + this._mux.setAttached(this.id, false); } this.emit('exit', exitCode); }); @@ -1883,14 +1895,18 @@ export class Session extends EventEmitter { } /** - * Sends input via GNU Screen's `screen -X stuff` command. + * Sends input via the terminal multiplexer's direct input mechanism. * * More reliable than direct PTY write for programmatic input, especially - * with Claude CLI which uses Ink (React for terminals). Text and Enter - * are sent as separate commands internally. + * with Claude CLI which uses Ink (React for terminals). + * - tmux: `send-keys -l 'text' Enter` (single command, no delay) + * - screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries) + * + * Method name kept as `writeViaScreen` for backward compatibility with + * respawn-controller and other callers. * * @param data - Input data with optional `\r` for Enter - * @returns true if input was sent, false if no screen session or PTY + * @returns true if input was sent, false if no mux session or PTY * * @example * ```typescript @@ -1899,8 +1915,8 @@ export class Session extends EventEmitter { * ``` */ writeViaScreen(data: string): boolean { - if (this._screenManager && this._screenSession) { - return this._screenManager.sendInput(this.id, data); + if (this._mux && this._muxSession) { + return this._mux.sendInput(this.id, data); } // Fallback to PTY write if (this.ptyProcess) { @@ -2091,21 +2107,21 @@ export class Session extends EventEmitter { // Clear task description cache to prevent memory leak this._recentTaskDescriptions.clear(); - // Kill the associated screen session if requested - if (killScreen && this._screenManager) { - // Try to kill screen even if _screenSession is not set (e.g., restored sessions) + // Kill the associated mux session if requested + if (killScreen && this._mux) { + // Try to kill mux session even if _muxSession is not set (e.g., restored sessions) try { - const killed = await this._screenManager.killScreen(this.id); + const killed = await this._mux.killSession(this.id); if (killed) { - console.log('[Session] Killed screen session for:', this.id); + console.log('[Session] Killed mux session for:', this.id); } } catch (err) { - console.error('[Session] Failed to kill screen session:', err); + console.error('[Session] Failed to kill mux session:', err); } - this._screenSession = null; - } else if (this._screenSession && !killScreen) { - console.log('[Session] Keeping screen session alive:', this._screenSession.screenName); - this._screenSession = null; // Detach but don't kill + this._muxSession = null; + } else if (this._muxSession && !killScreen) { + console.log('[Session] Keeping mux session alive:', this._muxSession.muxName); + this._muxSession = null; // Detach but don't kill } } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts new file mode 100644 index 00000000..768b16c3 --- /dev/null +++ b/src/tmux-manager.ts @@ -0,0 +1,888 @@ +/** + * @fileoverview tmux session manager for persistent Claude sessions. + * + * This module provides the TmuxManager class which creates and manages + * tmux sessions that wrap Claude CLI processes. tmux provides: + * + * - **Persistence**: Sessions survive server restarts and disconnects + * - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup + * - **Resource tracking**: Memory, CPU, and child process stats per session + * - **Reliable input**: `send-keys -l` sends literal text in a single command + * - **Teammate support**: Immutable pane IDs enable targeting individual teammates + * + * tmux sessions are named `claudeman-{sessionId}` and stored in ~/.claudeman/mux-sessions.json. + * + * Key advantages over GNU Screen: + * - `send-keys 'text' Enter` eliminates the text+CR split hack (no 100ms delay, no retries) + * - `list-sessions -F` provides structured queries (no regex parsing) + * - `display-message -p '#{pane_pid}'` for reliable PID discovery + * - Single server architecture vs per-session processes + * + * @module tmux-manager + */ + +import { EventEmitter } from 'node:events'; +import { spawn, execSync } from 'node:child_process'; +import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js'; +import { wrapWithNice } from './screen-manager.js'; +import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js'; + +// ============================================================================ +// Claude CLI PATH Resolution +// ============================================================================ + +/** Common directories where the Claude CLI binary may be installed */ +const CLAUDE_SEARCH_DIRS = [ + `${homedir()}/.local/bin`, + `${homedir()}/.claude/local`, + '/usr/local/bin', + `${homedir()}/.npm-global/bin`, + `${homedir()}/bin`, +]; + +// ============================================================================ +// Timing Constants +// ============================================================================ + +/** Timeout for exec commands (5 seconds) */ +const EXEC_TIMEOUT_MS = 5000; + +/** Delay after tmux session creation (300ms — faster than screen's 500ms) */ +const TMUX_CREATION_WAIT_MS = 300; + +/** Delay after tmux kill command (200ms) */ +const TMUX_KILL_WAIT_MS = 200; + +/** Delay for graceful shutdown (100ms) */ +const GRACEFUL_SHUTDOWN_WAIT_MS = 100; + +/** Default stats collection interval (2 seconds) */ +const DEFAULT_STATS_INTERVAL_MS = 2000; + +/** Cached directory containing the claude binary */ +let _claudeDir: string | null = null; + +/** + * Finds the directory containing the `claude` binary. + * Returns null if not found (will rely on PATH as-is). + */ +function findClaudeDir(): string | null { + if (_claudeDir !== null) return _claudeDir; + + try { + const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); + if (result && existsSync(result)) { + _claudeDir = dirname(result); + return _claudeDir; + } + } catch { + // not in PATH + } + + for (const dir of CLAUDE_SEARCH_DIRS) { + if (existsSync(`${dir}/claude`)) { + _claudeDir = dir; + return _claudeDir; + } + } + + _claudeDir = ''; // mark as searched, not found + return null; +} + +/** Path to persisted mux session metadata */ +const MUX_SESSIONS_FILE = join(homedir(), '.claudeman', 'mux-sessions.json'); + +/** Path to legacy screen sessions (for migration) */ +const LEGACY_SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json'); + +/** Regex to validate tmux session names (only allow safe characters) */ +const SAFE_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/; + +/** Regex to validate working directory paths (no shell metacharacters) */ +const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/; + +/** + * Validates that a session name contains only safe characters. + * Prevents command injection via malformed session IDs. + */ +function isValidMuxName(name: string): boolean { + return SAFE_MUX_NAME_PATTERN.test(name); +} + +/** + * Validates that a path contains only safe characters. + * Prevents command injection via malformed paths. + */ +function isValidPath(path: string): boolean { + if (path.includes(';') || path.includes('&') || path.includes('|') || + path.includes('$') || path.includes('`') || path.includes('(') || + path.includes(')') || path.includes('{') || path.includes('}') || + path.includes('<') || path.includes('>') || path.includes("'") || + path.includes('"') || path.includes('\n') || path.includes('\r')) { + return false; + } + if (path.includes('..')) { + return false; + } + return SAFE_PATH_PATTERN.test(path); +} + +/** + * Manages tmux sessions that wrap Claude CLI or shell processes. + * + * Implements the TerminalMultiplexer interface for use as a drop-in + * replacement for ScreenManager. + * + * @example + * ```typescript + * const manager = new TmuxManager(); + * + * // Create a tmux session for Claude + * const session = await manager.createSession(sessionId, '/project', 'claude'); + * + * // Send input (single command, no delay!) + * manager.sendInput(sessionId, '/clear\r'); + * + * // Kill when done + * await manager.killSession(sessionId); + * ``` + */ +export class TmuxManager extends EventEmitter implements TerminalMultiplexer { + readonly backend = 'tmux' as const; + private sessions: Map = new Map(); + private statsInterval: NodeJS.Timeout | null = null; + + constructor() { + super(); + this.loadSessions(); + } + + // Load saved sessions from disk + private loadSessions(): void { + try { + if (existsSync(MUX_SESSIONS_FILE)) { + const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8'); + const data = JSON.parse(content); + if (Array.isArray(data)) { + for (const session of data) { + this.sessions.set(session.sessionId, session); + } + } + } else if (existsSync(LEGACY_SCREENS_FILE)) { + // Migration: load from legacy screens.json + console.log('[TmuxManager] Migrating sessions from legacy screens.json'); + const content = readFileSync(LEGACY_SCREENS_FILE, 'utf-8'); + const data = JSON.parse(content); + if (Array.isArray(data)) { + for (const screen of data) { + const session: MuxSession = { + sessionId: screen.sessionId, + muxName: screen.screenName, + pid: screen.pid, + createdAt: screen.createdAt, + workingDir: screen.workingDir, + mode: screen.mode, + attached: screen.attached, + name: screen.name, + respawnConfig: screen.respawnConfig, + ralphEnabled: screen.ralphEnabled, + }; + this.sessions.set(session.sessionId, session); + } + this.saveSessions(); + console.log(`[TmuxManager] Migrated ${data.length} sessions from screens.json`); + } + } + } catch (err) { + console.error('[TmuxManager] Failed to load sessions:', err); + } + } + + /** + * Save sessions to disk asynchronously. + */ + private saveSessions(): void { + try { + const dir = dirname(MUX_SESSIONS_FILE); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const data = Array.from(this.sessions.values()); + const json = JSON.stringify(data, null, 2); + + writeFile(MUX_SESSIONS_FILE, json, (err) => { + if (err) { + console.error('[TmuxManager] Failed to save sessions:', err); + } + }); + } catch (err) { + console.error('[TmuxManager] Failed to save sessions:', err); + } + } + + /** + * Creates a new tmux session wrapping Claude CLI or a shell. + */ + async createSession( + sessionId: string, + workingDir: string, + mode: 'claude' | 'shell', + name?: string, + niceConfig?: NiceConfig, + ): Promise { + const muxName = `claudeman-${sessionId.slice(0, 8)}`; + + if (!isValidMuxName(muxName)) { + throw new Error('Invalid session name: contains unsafe characters'); + } + if (!isValidPath(workingDir)) { + throw new Error('Invalid working directory path: contains unsafe characters'); + } + + const claudeDir = findClaudeDir(); + const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : ''; + + const envExports = [ + 'export CLAUDEMAN_SCREEN=1', + `export CLAUDEMAN_SESSION_ID=${sessionId}`, + `export CLAUDEMAN_SCREEN_NAME=${muxName}`, + `export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`, + ].join(' && '); + + const baseCmd = mode === 'claude' + ? `claude --dangerously-skip-permissions --session-id "${sessionId}"` + : '$SHELL'; + + const config = niceConfig || DEFAULT_NICE_CONFIG; + const cmd = wrapWithNice(baseCmd, config); + + try { + // Build the full command to run inside tmux + const fullCmd = `${pathExport}${envExports} && ${cmd}`; + + // Create tmux session in detached mode + // -d: don't attach, -s: session name, -c: starting directory + // -x/-y: initial window size + const tmuxProcess = spawn('tmux', [ + 'new-session', + '-ds', muxName, + '-c', workingDir, + '-x', '120', + '-y', '40', + 'bash', '-c', fullCmd, + ], { + cwd: workingDir, + detached: true, + stdio: 'ignore', + }); + + tmuxProcess.unref(); + + // Wait for tmux session to start + await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); + + // Disable tmux status bar — Claudeman's web UI provides session info, + // and the status bar can't be copied and wastes a terminal row + try { + execSync( + `tmux set-option -t "${muxName}" status off`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + } catch { + // Non-critical — session still works with status bar + } + + // Get the PID of the pane process + const pid = this.getPanePid(muxName); + if (!pid) { + throw new Error('Failed to get tmux pane PID'); + } + + const session: MuxSession = { + sessionId, + muxName, + pid, + createdAt: Date.now(), + workingDir, + mode, + attached: false, + name, + }; + + this.sessions.set(sessionId, session); + this.saveSessions(); + this.emit('sessionCreated', session); + + return session; + } catch (err) { + throw new Error(`Failed to create tmux session: ${getErrorMessage(err)}`); + } + } + + /** + * Get the PID of the process running in the tmux pane. + */ + private getPanePid(muxName: string): number | null { + if (!isValidMuxName(muxName)) { + console.error('[TmuxManager] Invalid session name in getPanePid:', muxName); + return null; + } + + try { + const output = execSync( + `tmux display-message -t "${muxName}" -p '#{pane_pid}'`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ).trim(); + const pid = parseInt(output, 10); + return Number.isNaN(pid) ? null : pid; + } catch { + return null; + } + } + + /** + * Check if a tmux session exists. + */ + private sessionExists(muxName: string): boolean { + try { + execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); + return true; + } catch { + return false; + } + } + + // Get all child process PIDs recursively + private getChildPids(pid: number): number[] { + const pids: number[] = []; + try { + const output = execSync(`pgrep -P ${pid}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); + if (output) { + for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !Number.isNaN(p))) { + pids.push(childPid); + pids.push(...this.getChildPids(childPid)); + } + } + } catch { + // No children or command failed + } + return pids; + } + + // Check if a process is still alive + private isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + // Verify all PIDs are dead, with retry + private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise { + const startTime = Date.now(); + const checkInterval = 100; + + while (Date.now() - startTime < maxWaitMs) { + const aliveCount = pids.filter(pid => this.isProcessAlive(pid)).length; + if (aliveCount === 0) { + return true; + } + await new Promise(resolve => setTimeout(resolve, checkInterval)); + } + + const stillAlive = pids.filter(pid => this.isProcessAlive(pid)); + if (stillAlive.length > 0) { + console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`); + } + return stillAlive.length === 0; + } + + /** + * Kill a tmux session and all its child processes. + * Uses the same 4-strategy approach as ScreenManager. + */ + async killSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + return false; + } + + // Get current PID (may have changed) + const currentPid = this.getPanePid(session.muxName) || session.pid; + + console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`); + + const allPids: number[] = [currentPid]; + + // Strategy 1: Kill all child processes recursively + let childPids = this.getChildPids(currentPid); + if (childPids.length > 0) { + console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`); + allPids.push(...childPids); + + for (const childPid of [...childPids].reverse()) { + if (this.isProcessAlive(childPid)) { + try { + process.kill(childPid, 'SIGTERM'); + } catch { + // Process may already be dead + } + } + } + + await new Promise(resolve => setTimeout(resolve, TMUX_KILL_WAIT_MS)); + + childPids = this.getChildPids(currentPid); + for (const childPid of childPids) { + if (this.isProcessAlive(childPid)) { + try { + process.kill(childPid, 'SIGKILL'); + } catch { + // Process already terminated + } + } + } + } + + // Strategy 2: Kill the entire process group + if (this.isProcessAlive(currentPid)) { + try { + process.kill(-currentPid, 'SIGTERM'); + await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS)); + if (this.isProcessAlive(currentPid)) { + process.kill(-currentPid, 'SIGKILL'); + } + } catch { + // Process group may not exist or already terminated + } + } + + // Strategy 3: Kill tmux session by name + try { + execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, { + timeout: EXEC_TIMEOUT_MS, + }); + } catch { + // Session may already be dead + } + + // Strategy 4: Direct kill by PID as final fallback + if (this.isProcessAlive(currentPid)) { + try { + process.kill(currentPid, 'SIGKILL'); + } catch { + // Already dead + } + } + + // Verify all processes are dead + const allDead = await this.verifyProcessesDead(allPids, 2000); + if (!allDead) { + console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`); + } + + this.sessions.delete(sessionId); + this.saveSessions(); + this.emit('sessionKilled', { sessionId }); + + return true; + } + + getSessions(): MuxSession[] { + return Array.from(this.sessions.values()); + } + + getSession(sessionId: string): MuxSession | undefined { + return this.sessions.get(sessionId); + } + + updateSessionName(sessionId: string, name: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + return false; + } + session.name = name; + this.saveSessions(); + return true; + } + + /** + * Reconcile tracked sessions with actual running tmux sessions. + */ + async reconcileSessions(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }> { + const alive: string[] = []; + const dead: string[] = []; + const discovered: string[] = []; + + // Check known sessions + for (const [sessionId, session] of this.sessions) { + if (this.sessionExists(session.muxName)) { + alive.push(sessionId); + // Update PID if it changed + const pid = this.getPanePid(session.muxName); + if (pid && pid !== session.pid) { + session.pid = pid; + } + } else { + dead.push(sessionId); + this.sessions.delete(sessionId); + this.emit('sessionDied', { sessionId }); + } + } + + // Discover unknown claudeman sessions + try { + const output = execSync( + "tmux list-sessions -F '#{session_name}' 2>/dev/null || true", + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ).trim(); + + for (const line of output.split('\n')) { + const sessionName = line.trim(); + if (!sessionName || !sessionName.startsWith('claudeman-')) continue; + + // Check if this session is already known + let isKnown = false; + for (const session of this.sessions.values()) { + if (session.muxName === sessionName) { + isKnown = true; + break; + } + } + + if (!isKnown) { + // Extract session ID fragment from name + const fragment = sessionName.replace('claudeman-', ''); + const sessionId = `restored-${fragment}`; + const pid = this.getPanePid(sessionName); + + if (pid) { + const session: MuxSession = { + sessionId, + muxName: sessionName, + pid, + createdAt: Date.now(), + workingDir: process.cwd(), + mode: 'claude', + attached: false, + name: `Restored: ${sessionName}`, + }; + this.sessions.set(sessionId, session); + discovered.push(sessionId); + console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); + } + } + } + } catch (err) { + console.error('[TmuxManager] Failed to discover sessions:', err); + } + + if (dead.length > 0 || discovered.length > 0) { + this.saveSessions(); + } + + return { alive, dead, discovered }; + } + + async getProcessStats(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + return null; + } + + try { + const psOutput = execSync( + `ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ).trim(); + + const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0); + + let childCount = 0; + try { + const childOutput = execSync( + `pgrep -P ${session.pid} | wc -l`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ).trim(); + childCount = parseInt(childOutput, 10) || 0; + } catch { + // No children or command failed + } + + return { + memoryMB: Math.round(rss / 1024 * 10) / 10, + cpuPercent: Math.round(cpu * 10) / 10, + childCount, + updatedAt: Date.now(), + }; + } catch { + return null; + } + } + + async getSessionsWithStats(): Promise { + const sessions = Array.from(this.sessions.values()); + if (sessions.length === 0) { + return []; + } + + const sessionPids = sessions.map(s => s.pid); + const statsMap = new Map(); + + try { + // Step 1: Get descendant PIDs + const descendantMap = new Map(); + + const pgrepOutput = execSync( + `for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ).trim(); + + for (const line of pgrepOutput.split('\n')) { + const [pidStr, childrenStr] = line.split(':'); + const sessionPid = parseInt(pidStr, 10); + if (!Number.isNaN(sessionPid)) { + const children = (childrenStr || '') + .split(',') + .map(s => parseInt(s.trim(), 10)) + .filter(n => !Number.isNaN(n) && n > 0); + descendantMap.set(sessionPid, children); + } + } + + // Step 2: Collect all PIDs + const allPids = new Set(sessionPids); + for (const children of descendantMap.values()) { + for (const child of children) { + allPids.add(child); + } + } + + // Step 3: Single ps call + const pidArray = Array.from(allPids); + if (pidArray.length > 0) { + const psOutput = execSync( + `ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ).trim(); + + const processStats = new Map(); + for (const line of psOutput.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + const pid = parseInt(parts[0], 10); + const rss = parseFloat(parts[1]) || 0; + const cpu = parseFloat(parts[2]) || 0; + if (!Number.isNaN(pid)) { + processStats.set(pid, { rss, cpu }); + } + } + } + + // Step 4: Aggregate stats + for (const sessionPid of sessionPids) { + const children = descendantMap.get(sessionPid) || []; + const sessionStats = processStats.get(sessionPid) || { rss: 0, cpu: 0 }; + + let totalRss = sessionStats.rss; + let totalCpu = sessionStats.cpu; + + for (const childPid of children) { + const childStats = processStats.get(childPid); + if (childStats) { + totalRss += childStats.rss; + totalCpu += childStats.cpu; + } + } + + statsMap.set(sessionPid, { + memoryMB: Math.round(totalRss / 1024 * 10) / 10, + cpuPercent: Math.round(totalCpu * 10) / 10, + childCount: children.length, + updatedAt: Date.now(), + }); + } + } + } catch { + // Fall back to individual queries + const statsPromises = sessions.map(session => this.getProcessStats(session.sessionId)); + const results = await Promise.allSettled(statsPromises); + return sessions.map((session, i) => ({ + ...session, + stats: results[i].status === 'fulfilled' ? (results[i].value ?? undefined) : undefined, + })); + } + + return sessions.map(session => ({ + ...session, + stats: statsMap.get(session.pid) || undefined, + })); + } + + startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void { + if (this.statsInterval) { + clearInterval(this.statsInterval); + } + + this.statsInterval = setInterval(async () => { + try { + const sessionsWithStats = await this.getSessionsWithStats(); + this.emit('statsUpdated', sessionsWithStats); + } catch (err) { + console.error('[TmuxManager] Stats collection error:', err); + } + }, intervalMs); + } + + stopStatsCollection(): void { + if (this.statsInterval) { + clearInterval(this.statsInterval); + this.statsInterval = null; + } + } + + destroy(): void { + this.stopStatsCollection(); + } + + registerSession(session: MuxSession): void { + this.sessions.set(session.sessionId, session); + this.saveSessions(); + } + + setAttached(sessionId: string, attached: boolean): void { + const session = this.sessions.get(sessionId); + if (session) { + session.attached = attached; + this.saveSessions(); + } + } + + updateRespawnConfig(sessionId: string, config: PersistedRespawnConfig | undefined): void { + const session = this.sessions.get(sessionId); + if (session) { + session.respawnConfig = config; + this.saveSessions(); + } + } + + clearRespawnConfig(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session && session.respawnConfig) { + delete session.respawnConfig; + this.saveSessions(); + } + } + + updateRalphEnabled(sessionId: string, enabled: boolean): void { + const session = this.sessions.get(sessionId); + if (session) { + session.ralphEnabled = enabled; + this.saveSessions(); + } + } + + /** + * Send input directly to a tmux session using `send-keys`. + * + * This is significantly simpler than Screen's approach: + * - `-l` flag sends literal text (no key interpretation) + * - `Enter` key is sent as a separate argument (not a shell escape) + * - Single command, no delay, no retry loop needed + */ + sendInput(sessionId: string, input: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`); + return false; + } + + console.log(`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`); + + if (!isValidMuxName(session.muxName)) { + console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName); + return false; + } + + try { + const hasCarriageReturn = input.includes('\r'); + const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); + + if (textPart && hasCarriageReturn) { + // Send text + Enter in a single command + // -l flag = literal text (no special key interpretation) + // 'Enter' after -l text = Enter key + execSync( + `tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + execSync( + `tmux send-keys -t "${session.muxName}" Enter`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + } else if (textPart) { + // Text only, no Enter + execSync( + `tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + } else if (hasCarriageReturn) { + // Enter only + execSync( + `tmux send-keys -t "${session.muxName}" Enter`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + } + + return true; + } catch (err) { + console.error('[TmuxManager] Failed to send input:', err); + return false; + } + } + + getAttachCommand(): string { + return 'tmux'; + } + + getAttachArgs(muxName: string): string[] { + return ['attach-session', '-t', muxName]; + } + + isAvailable(): boolean { + return TmuxManager.isTmuxAvailable(); + } + + /** + * Check if tmux is available on the system. + */ + static isTmuxAvailable(): boolean { + try { + execSync('which tmux', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }); + return true; + } catch { + return false; + } + } +} + +/** + * Shell-escape a string for use as a single argument. + * Wraps in single quotes, escaping any embedded single quotes. + */ +function shellescape(str: string): string { + // Replace single quotes with '\'' (end quote, escaped quote, restart quote) + return "'" + str.replace(/'/g, "'\\''") + "'"; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 06d652ef..e597d6a6 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1500,6 +1500,12 @@ class ClaudemanApp { this.terminal.onData((data) => { if (this.activeSessionId) { + // Filter out terminal query responses that xterm.js generates automatically. + // These are responses to DA (Device Attributes), DSR (Device Status Report), etc. + // sent by tmux when attaching. Without this filter, they appear as typed text. + // Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR) + if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return; + this._pendingInput += data; // Flush immediately for control characters (Enter, Ctrl+C, etc.) diff --git a/src/web/server.ts b/src/web/server.ts index 1b2b60f6..19ad6a97 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -22,7 +22,8 @@ import { EventEmitter } from 'node:events'; import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js'; import { fileStreamManager } from '../file-stream-manager.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; -import { ScreenManager } from '../screen-manager.js'; +import type { TerminalMultiplexer } from '../mux-interface.js'; +import { createMultiplexer } from '../mux-factory.js'; import { getStore } from '../state-store.js'; import { generateClaudeMd } from '../templates/claude-md.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; @@ -322,7 +323,7 @@ export class WebServer extends EventEmitter { private port: number; private https: boolean; private testMode: boolean; - private screenManager: ScreenManager; + private mux: TerminalMultiplexer; // Terminal batching for performance private terminalBatches: Map = new Map(); private terminalBatchTimer: NodeJS.Timeout | null = null; @@ -388,19 +389,28 @@ export class WebServer extends EventEmitter { } else { this.app = Fastify({ logger: false }); } - this.screenManager = new ScreenManager(); + this.mux = createMultiplexer(); - // Set up screen manager event listeners - this.screenManager.on('screenCreated', (screen) => { + // Set up mux event listeners (event names kept for SSE backward compat) + this.mux.on('screenCreated', (screen) => { this.broadcast('screen:created', screen); }); - this.screenManager.on('screenKilled', (data) => { + this.mux.on('sessionCreated', (session) => { + this.broadcast('screen:created', session); + }); + this.mux.on('screenKilled', (data) => { this.broadcast('screen:killed', data); }); - this.screenManager.on('screenDied', (data) => { + this.mux.on('sessionKilled', (data) => { + this.broadcast('screen:killed', data); + }); + this.mux.on('screenDied', (data) => { this.broadcast('screen:died', data); }); - this.screenManager.on('statsUpdated', (screens) => { + this.mux.on('sessionDied', (data) => { + this.broadcast('screen:died', data); + }); + this.mux.on('statsUpdated', (screens) => { this.broadcast('screen:statsUpdated', screens); }); @@ -691,7 +701,7 @@ export class WebServer extends EventEmitter { workingDir, mode: body.mode || 'claude', name: body.name || '', - screenManager: this.screenManager, + screenManager: this.mux, useScreen: true, niceConfig: globalNice, }); @@ -718,7 +728,7 @@ export class WebServer extends EventEmitter { const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH); session.name = name; // Also update the screen name if this session has a screen - this.screenManager.updateScreenName(id, session.name); + this.mux.updateSessionName(id, session.name); this.persistSessionState(session); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); return { success: true, name: session.name }; @@ -1229,7 +1239,7 @@ export class WebServer extends EventEmitter { session.ralphTracker.disableAutoEnable(); } // Persist Ralph enabled state - this.screenManager.updateRalphEnabled(id, enabled); + this.mux.updateRalphEnabled(id, enabled); } // Configure the Ralph tracker @@ -1654,7 +1664,7 @@ export class WebServer extends EventEmitter { } // Return pre-saved config from screens.json - const preConfig = this.screenManager.getScreen(id)?.respawnConfig; + const preConfig = this.mux.getSession(id)?.respawnConfig; if (preConfig) { return { success: true, config: preConfig, active: false }; } @@ -1676,7 +1686,7 @@ export class WebServer extends EventEmitter { let controller = this.respawnControllers.get(id); if (!controller) { // Merge request body with pre-saved config from screens.json - const preConfig = this.screenManager.getScreen(id)?.respawnConfig; + const preConfig = this.mux.getSession(id)?.respawnConfig; const config = body || preConfig ? { ...preConfig, ...body } : undefined; controller = new RespawnController(session, config); this.respawnControllers.set(id, controller); @@ -1718,7 +1728,7 @@ export class WebServer extends EventEmitter { } // Clear persisted respawn config - this.screenManager.clearRespawnConfig(id); + this.mux.clearRespawnConfig(id); // Update state.json (respawnConfig removed) const session = this.sessions.get(id); @@ -1758,7 +1768,7 @@ export class WebServer extends EventEmitter { } // No controller running - save as pre-config for when respawn starts - const existing = this.screenManager.getScreen(id); + const existing = this.mux.getSession(id); const currentConfig = existing?.respawnConfig; const merged: PersistedRespawnConfig = { enabled: config.enabled ?? currentConfig?.enabled ?? false, @@ -1782,7 +1792,7 @@ export class WebServer extends EventEmitter { aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000, durationMinutes: currentConfig?.durationMinutes, }; - this.screenManager.updateRespawnConfig(id, merged); + this.mux.updateRespawnConfig(id, merged); this.persistSessionState(session); this.broadcast('respawn:configUpdated', { sessionId: id, config: merged }); return { success: true, config: merged }; @@ -1866,7 +1876,7 @@ export class WebServer extends EventEmitter { } // Create and start new respawn controller (merge with pre-saved config) - const preConfig = this.screenManager.getScreen(id)?.respawnConfig; + const preConfig = this.mux.getSession(id)?.respawnConfig; const config = body?.config || preConfig ? { ...preConfig, ...body?.config } : undefined; const controller = new RespawnController(session, config); this.respawnControllers.set(id, controller); @@ -2421,7 +2431,7 @@ export class WebServer extends EventEmitter { const niceConfig = this.getGlobalNiceConfig(); const session = new Session({ workingDir: casePath, - screenManager: this.screenManager, + screenManager: this.mux, useScreen: true, mode: mode, niceConfig: niceConfig, @@ -2601,7 +2611,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Create temporary session for the AI call using Opus 4.5 for deep reasoning const session = new Session({ workingDir: process.cwd(), - screenManager: this.screenManager, + screenManager: this.mux, useScreen: false, // No screen needed for one-shot mode: 'claude', }); @@ -2726,7 +2736,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } } - const orchestrator = new PlanOrchestrator(this.screenManager, process.cwd(), outputDir); + const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir); // Store orchestrator for potential cancellation via API (not on disconnect) // Plan generation continues even if browser disconnects - only explicit cancel stops it @@ -3214,35 +3224,35 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Get all tracked screens with stats this.app.get('/api/screens', async () => { - const screens = await this.screenManager.getScreensWithStats(); + const screens = await this.mux.getSessionsWithStats(); return { screens, - screenAvailable: ScreenManager.isScreenAvailable() + screenAvailable: this.mux.isAvailable() }; }); // Kill a screen session this.app.delete('/api/screens/:sessionId', async (req) => { const { sessionId } = req.params as { sessionId: string }; - const success = await this.screenManager.killScreen(sessionId); + const success = await this.mux.killSession(sessionId); return { success }; }); // Reconcile screens (find dead ones) this.app.post('/api/screens/reconcile', async () => { - const result = await this.screenManager.reconcileScreens(); + const result = await this.mux.reconcileSessions(); return result; }); // Start stats collection this.app.post('/api/screens/stats/start', async () => { - this.screenManager.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); + this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); return { success: true }; }); // Stop stats collection this.app.post('/api/screens/stats/stop', async () => { - this.screenManager.stopStatsCollection(); + this.mux.stopStatsCollection(); return { success: true }; }); @@ -3530,7 +3540,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs, durationMinutes, }; - this.screenManager.updateRespawnConfig(sessionId, persistedConfig); + this.mux.updateRespawnConfig(sessionId, persistedConfig); } // Get system CPU and memory usage @@ -4840,7 +4850,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; private async restoreScreenSessions(): Promise { try { // Reconcile screens to find which ones are still alive (also discovers unknown screens) - const { alive, dead, discovered } = await this.screenManager.reconcileScreens(); + const { alive, dead, discovered } = await this.mux.reconcileSessions(); if (discovered.length > 0) { console.log(`[Server] Discovered ${discovered.length} unknown screen session(s)`); @@ -4850,30 +4860,30 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; console.log(`[Server] Found ${alive.length + discovered.length} alive screen session(s) from previous run`); // For each alive screen, create a Session object if it doesn't exist - const screens = this.screenManager.getScreens(); + const screens = this.mux.getSessions(); for (const screen of screens) { if (!this.sessions.has(screen.sessionId)) { // Restore session settings from state.json (single source of truth) const savedState = this.store.getSession(screen.sessionId); - // Determine the correct session name (priority: savedState > screen > screenName) + // Determine the correct session name (priority: savedState > screen > muxName) // This ensures renamed sessions keep their name after server restart - const sessionName = savedState?.name || screen.name || screen.screenName; + const sessionName = savedState?.name || screen.name || screen.muxName; - // Create a session object for this screen with the existing screenSession + // Create a session object for this mux session with the existing session const session = new Session({ id: screen.sessionId, // Preserve the original session ID workingDir: screen.workingDir, mode: screen.mode, name: sessionName, - screenManager: this.screenManager, - useScreen: true, - screenSession: screen // Pass the existing screen so startInteractive() can attach to it + mux: this.mux, + useMux: true, + muxSession: screen // Pass the existing session so startInteractive() can attach to it }); // Update screen name if it was a "Restored:" placeholder or doesn't match saved name if (savedState?.name && screen.name !== savedState.name) { - this.screenManager.updateScreenName(screen.sessionId, savedState.name); + this.mux.updateSessionName(screen.sessionId, savedState.name); } if (savedState) { // Auto-compact @@ -4977,12 +4987,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.persistSessionState(session); // Mark it as restored (not started yet - user needs to attach) - console.log(`[Server] Restored session ${session.id} from screen ${screen.screenName}`); + console.log(`[Server] Restored session ${session.id} from mux ${screen.muxName}`); } } // Start stats collection to show screen info - this.screenManager.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); + this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); } if (dead.length > 0) { @@ -5053,8 +5063,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.scheduledCleanupTimer = null; } - // Stop screen manager and flush pending saves - this.screenManager.destroy(); + // Stop multiplexer and flush pending saves + this.mux.destroy(); // Clear all pending respawn start timers (from restoration grace period) for (const timer of this.pendingRespawnStarts.values()) { diff --git a/test/mux-factory.test.ts b/test/mux-factory.test.ts new file mode 100644 index 00000000..fa367081 --- /dev/null +++ b/test/mux-factory.test.ts @@ -0,0 +1,164 @@ +/** + * @fileoverview Unit tests for mux-factory + * + * Tests detection logic with mocked `which` commands. + * Tests CLAUDEMAN_MUX env var override. + * Tests error when neither multiplexer is available. + * + * Port: N/A (no server needed) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createMultiplexer } from '../src/mux-factory.js'; +import { ScreenManager } from '../src/screen-manager.js'; +import { TmuxManager } from '../src/tmux-manager.js'; + +// Spy on static availability methods +const tmuxAvailableSpy = vi.spyOn(TmuxManager, 'isTmuxAvailable'); +const screenAvailableSpy = vi.spyOn(ScreenManager, 'isScreenAvailable'); + +// Mock child_process for TmuxManager constructor (it calls execSync for 'which claude') +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + execSync: vi.fn((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which claude')) { + return '/usr/local/bin/claude\n'; + } + if (typeof cmd === 'string' && cmd.includes('which tmux')) { + return '/usr/bin/tmux\n'; + } + if (typeof cmd === 'string' && cmd.includes('which screen')) { + return '/usr/bin/screen\n'; + } + return ''; + }), + spawn: vi.fn(() => ({ + unref: vi.fn(), + on: vi.fn(), + pid: 12345, + })), + }; +}); + +// Mock fs to avoid file I/O +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(false), + readFileSync: vi.fn(), + mkdirSync: vi.fn(), + writeFile: vi.fn((_path: string, _data: string, cb: (err: Error | null) => void) => cb(null)), + writeFileSync: vi.fn(), + readdirSync: vi.fn().mockReturnValue([]), + unlinkSync: vi.fn(), + }; +}); + +describe('createMultiplexer', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + originalEnv = process.env.CLAUDEMAN_MUX; + delete process.env.CLAUDEMAN_MUX; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.CLAUDEMAN_MUX = originalEnv; + } else { + delete process.env.CLAUDEMAN_MUX; + } + }); + + describe('auto-detection', () => { + it('should prefer tmux when both are available', () => { + tmuxAvailableSpy.mockReturnValue(true); + screenAvailableSpy.mockReturnValue(true); + + const mux = createMultiplexer(); + expect(mux.backend).toBe('tmux'); + mux.destroy(); + }); + + it('should fall back to screen when tmux is not available', () => { + tmuxAvailableSpy.mockReturnValue(false); + screenAvailableSpy.mockReturnValue(true); + + const mux = createMultiplexer(); + expect(mux.backend).toBe('screen'); + mux.destroy(); + }); + + it('should throw when neither is available', () => { + tmuxAvailableSpy.mockReturnValue(false); + screenAvailableSpy.mockReturnValue(false); + + expect(() => createMultiplexer()).toThrow('No terminal multiplexer found'); + }); + }); + + describe('forced parameter', () => { + it('should use tmux when forced', () => { + tmuxAvailableSpy.mockReturnValue(true); + screenAvailableSpy.mockReturnValue(true); + + const mux = createMultiplexer('tmux'); + expect(mux.backend).toBe('tmux'); + mux.destroy(); + }); + + it('should use screen when forced', () => { + tmuxAvailableSpy.mockReturnValue(true); + screenAvailableSpy.mockReturnValue(true); + + const mux = createMultiplexer('screen'); + expect(mux.backend).toBe('screen'); + mux.destroy(); + }); + + it('should throw when forced tmux is not available', () => { + tmuxAvailableSpy.mockReturnValue(false); + + expect(() => createMultiplexer('tmux')).toThrow('tmux requested'); + }); + + it('should throw when forced screen is not available', () => { + screenAvailableSpy.mockReturnValue(false); + + expect(() => createMultiplexer('screen')).toThrow('Screen requested'); + }); + }); + + describe('CLAUDEMAN_MUX env var', () => { + it('should respect CLAUDEMAN_MUX=screen', () => { + process.env.CLAUDEMAN_MUX = 'screen'; + tmuxAvailableSpy.mockReturnValue(true); + screenAvailableSpy.mockReturnValue(true); + + const mux = createMultiplexer(); + expect(mux.backend).toBe('screen'); + mux.destroy(); + }); + + it('should respect CLAUDEMAN_MUX=tmux', () => { + process.env.CLAUDEMAN_MUX = 'tmux'; + tmuxAvailableSpy.mockReturnValue(true); + screenAvailableSpy.mockReturnValue(true); + + const mux = createMultiplexer(); + expect(mux.backend).toBe('tmux'); + mux.destroy(); + }); + + it('should throw when CLAUDEMAN_MUX=screen but screen unavailable', () => { + process.env.CLAUDEMAN_MUX = 'screen'; + screenAvailableSpy.mockReturnValue(false); + + expect(() => createMultiplexer()).toThrow('Screen requested'); + }); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index 8ebbedcd..79c9291d 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,18 +2,18 @@ * @fileoverview Global test setup for Claudeman tests * * Provides: - * - Screen session concurrency limiter (max 10) + * - Session concurrency limiter (max 10 tmux/screen sessions) * - Tracked resource cleanup (only kills what tests create) * - Global beforeAll/afterAll hooks * * CRITICAL SAFETY GUARANTEES: - * 1. Pre-existing screens (captured at MODULE LOAD) are NEVER killed + * 1. Pre-existing screens/tmux sessions (captured at MODULE LOAD) are NEVER killed * 2. Current process screen ($CLAUDEMAN_SCREEN_NAME) is NEVER killed - * 3. Only screens explicitly registered via registerTestScreen() can be killed - * 4. All screen names must pass validation before being accepted + * 3. Only sessions explicitly registered via registerTestScreen()/registerTestTmuxSession() can be killed + * 4. All session names must pass validation before being accepted * * This setup ONLY cleans up resources that the test suite itself creates. - * It will NEVER kill Claude processes or screens that weren't spawned by tests. + * It will NEVER kill Claude processes or sessions that weren't spawned by tests. * This makes it safe to run tests from within a Claudeman-managed session. */ @@ -26,6 +26,9 @@ const MAX_CONCURRENT_SCREENS = 10; /** Track active screen sessions created during tests */ const activeTestScreens = new Set(); +/** Track active tmux sessions created during tests */ +const activeTestTmuxSessions = new Set(); + /** Track Claude PIDs spawned by tests (for cleanup) */ const activeTestClaudePids = new Set(); @@ -63,6 +66,25 @@ try { // Ignore errors during capture } +/** + * CRITICAL: Pre-existing tmux sessions captured at MODULE LOAD time. + * These sessions existed before any test code ran and must NEVER be killed. + */ +const preExistingTmuxSessionsAtModuleLoad = new Set(); + +// Capture pre-existing tmux sessions IMMEDIATELY when this module loads +try { + const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", { encoding: 'utf-8', timeout: 5000 }); + for (const line of output.trim().split('\n')) { + const name = line.trim(); + if (name) { + preExistingTmuxSessionsAtModuleLoad.add(name); + } + } +} catch { + // tmux may not be running or available +} + /** * Check if a screen name matches user-created patterns (w1-*, s1-*) */ @@ -132,6 +154,38 @@ function killTrackedTestScreens(): void { activeTestScreens.clear(); } +/** + * Check if a tmux session is protected and must NEVER be killed. + */ +function isTmuxSessionProtected(sessionName: string): boolean { + if (preExistingTmuxSessionsAtModuleLoad.has(sessionName)) { + return true; + } + if (preExistingTmuxSessions.has(sessionName)) { + return true; + } + return false; +} + +/** + * Kill only the tmux sessions that tests have registered via registerTestTmuxSession() + */ +function killTrackedTestTmuxSessions(): void { + for (const sessionName of activeTestTmuxSessions) { + if (isTmuxSessionProtected(sessionName)) { + console.warn(`[Test Setup] BLOCKED: Refusing to kill protected tmux session: ${sessionName}`); + continue; + } + try { + console.log(`[Test Setup] Killing test tmux session: ${sessionName}`); + execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`, { encoding: 'utf-8' }); + } catch { + // Ignore errors + } + } + activeTestTmuxSessions.clear(); +} + /** * Kill only the Claude processes that tests have registered via registerTestClaudePid() */ @@ -210,6 +264,24 @@ export function unregisterTestScreen(screenName: string): void { activeTestScreens.delete(screenName); } +/** + * Register a tmux session for tracking. + * SAFETY: Protected sessions will be skipped at cleanup time. + */ +export function registerTestTmuxSession(sessionName: string): void { + if (isTmuxSessionProtected(sessionName)) { + console.warn(`[Test Setup] WARNING: Registering protected tmux session ${sessionName} - will be skipped during cleanup`); + } + activeTestTmuxSessions.add(sessionName); +} + +/** + * Unregister a tmux session + */ +export function unregisterTestTmuxSession(sessionName: string): void { + activeTestTmuxSessions.delete(sessionName); +} + /** * Register a Claude PID for tracking (so it gets cleaned up after tests) */ @@ -243,6 +315,9 @@ export function forceCleanupAllTestResources(): void { // Kill all tracked test screens killTrackedTestScreens(); + // Kill all tracked test tmux sessions + killTrackedTestTmuxSessions(); + // Kill all tracked Claude processes killTrackedTestClaudeProcesses(); @@ -258,6 +333,9 @@ export function forceCleanupAllTestResources(): void { /** Screens that existed before tests started (never killed by cleanup) */ const preExistingScreens = new Set(); +/** Tmux sessions that existed before tests started (never killed by cleanup) */ +const preExistingTmuxSessions = new Set(); + /** * List all current claudeman-* screen session names */ @@ -313,6 +391,20 @@ beforeAll(async () => { for (const name of listClaudemanScreens()) { preExistingScreens.add(name); } + + // Record pre-existing tmux sessions so we never kill them + try { + const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", { encoding: 'utf-8', timeout: 5000 }); + for (const line of output.trim().split('\n')) { + const name = line.trim(); + if (name) { + preExistingTmuxSessions.add(name); + } + } + } catch { + // tmux may not be running + } + console.log(`[Test Setup] ${preExistingScreens.size} pre-existing screens preserved`); }); @@ -341,6 +433,9 @@ afterAll(async () => { if (activeTestScreens.size > 0) { console.warn(`[Test Setup] Warning: ${activeTestScreens.size} test screens weren't properly unregistered`); } + if (activeTestTmuxSessions.size > 0) { + console.warn(`[Test Setup] Warning: ${activeTestTmuxSessions.size} test tmux sessions weren't properly unregistered`); + } if (activeTestClaudePids.size > 0) { console.warn(`[Test Setup] Warning: ${activeTestClaudePids.size} test Claude PIDs weren't properly unregistered`); } @@ -351,10 +446,13 @@ afterAll(async () => { // Export utilities for tests that need them export { killTrackedTestScreens, + killTrackedTestTmuxSessions, killTrackedTestClaudeProcesses, MAX_CONCURRENT_SCREENS, isScreenProtected, + isTmuxSessionProtected, isTestScreen, isUserScreenPattern, preExistingScreensAtModuleLoad, + preExistingTmuxSessionsAtModuleLoad, }; diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts new file mode 100644 index 00000000..1f639c3a --- /dev/null +++ b/test/tmux-manager.test.ts @@ -0,0 +1,387 @@ +/** + * @fileoverview Unit tests for TmuxManager + * + * Tests validation functions, command construction, and parsing logic + * using mocked exec calls. Does NOT create or kill real tmux sessions. + * + * Port: N/A (no server needed) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { execSync } from 'node:child_process'; + +// Mock child_process +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + execSync: vi.fn(), + spawn: vi.fn(() => ({ + unref: vi.fn(), + on: vi.fn(), + pid: 12345, + })), + }; +}); + +// Mock fs to avoid file I/O +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(false), + readFileSync: vi.fn(), + mkdirSync: vi.fn(), + writeFile: vi.fn((_path: string, _data: string, cb: (err: Error | null) => void) => cb(null)), + }; +}); + +describe('TmuxManager', () => { + let manager: TmuxManager; + const mockedExecSync = vi.mocked(execSync); + + beforeEach(() => { + vi.clearAllMocks(); + // Default: which claude returns /usr/local/bin/claude + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which claude')) { + return '/usr/local/bin/claude\n'; + } + if (typeof cmd === 'string' && cmd.includes('which tmux')) { + return '/usr/bin/tmux\n'; + } + return ''; + }); + manager = new TmuxManager(); + }); + + afterEach(() => { + manager.destroy(); + }); + + describe('backend', () => { + it('should report tmux as backend', () => { + expect(manager.backend).toBe('tmux'); + }); + }); + + describe('getAttachCommand', () => { + it('should return tmux', () => { + expect(manager.getAttachCommand()).toBe('tmux'); + }); + }); + + describe('getAttachArgs', () => { + it('should return attach-session args', () => { + const args = manager.getAttachArgs('claudeman-abc12345'); + expect(args).toEqual(['attach-session', '-t', 'claudeman-abc12345']); + }); + }); + + describe('isAvailable', () => { + it('should return true when tmux is found', () => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which tmux')) { + return '/usr/bin/tmux\n'; + } + return ''; + }); + expect(TmuxManager.isTmuxAvailable()).toBe(true); + }); + + it('should return false when tmux is not found', () => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which tmux')) { + throw new Error('not found'); + } + return ''; + }); + expect(TmuxManager.isTmuxAvailable()).toBe(false); + }); + }); + + describe('sendInput', () => { + beforeEach(() => { + // Register a session for sendInput tests + manager.registerSession({ + sessionId: 'test-id', + muxName: 'claudeman-1e571234', + pid: 12345, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + }); + + it('should send text + Enter as two separate tmux commands', () => { + const calls: string[] = []; + mockedExecSync.mockImplementation((cmd: string) => { + const cmdStr = String(cmd); + if (cmdStr.includes('send-keys')) { + calls.push(cmdStr); + } + return ''; + }); + + manager.sendInput('test-id', '/clear\r'); + + // Should have 2 calls: send-keys -l text, then send-keys Enter + expect(calls).toHaveLength(2); + expect(calls[0]).toContain('send-keys'); + expect(calls[0]).toContain('-l'); + expect(calls[0]).toContain('/clear'); + expect(calls[1]).toContain('send-keys'); + expect(calls[1]).toContain('Enter'); + }); + + it('should send text only (no Enter) when no \\r present', () => { + const calls: string[] = []; + mockedExecSync.mockImplementation((cmd: string) => { + const cmdStr = String(cmd); + if (cmdStr.includes('send-keys')) { + calls.push(cmdStr); + } + return ''; + }); + + manager.sendInput('test-id', 'hello world'); + + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('send-keys'); + expect(calls[0]).toContain('-l'); + expect(calls[0]).not.toContain('Enter'); + }); + + it('should send Enter only when input is just \\r', () => { + const calls: string[] = []; + mockedExecSync.mockImplementation((cmd: string) => { + const cmdStr = String(cmd); + if (cmdStr.includes('send-keys')) { + calls.push(cmdStr); + } + return ''; + }); + + manager.sendInput('test-id', '\r'); + + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('send-keys'); + expect(calls[0]).toContain('Enter'); + expect(calls[0]).not.toContain('-l'); + }); + + it('should return false for unknown session', () => { + const result = manager.sendInput('nonexistent', 'hello\r'); + expect(result).toBe(false); + }); + + it('should use -l flag for literal text (no key interpretation)', () => { + const calls: string[] = []; + mockedExecSync.mockImplementation((cmd: string) => { + const cmdStr = String(cmd); + if (cmdStr.includes('send-keys')) { + calls.push(cmdStr); + } + return ''; + }); + + // Text that could be interpreted as tmux keys without -l + manager.sendInput('test-id', 'C-c'); + + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('-l'); + }); + + it('should target the correct session name', () => { + const calls: string[] = []; + mockedExecSync.mockImplementation((cmd: string) => { + const cmdStr = String(cmd); + if (cmdStr.includes('send-keys')) { + calls.push(cmdStr); + } + return ''; + }); + + manager.sendInput('test-id', 'test\r'); + + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call).toContain('claudeman-1e571234'); + } + }); + }); + + describe('reconcileSessions', () => { + it('should detect alive sessions', async () => { + manager.registerSession({ + sessionId: 'alive-1', + muxName: 'claudeman-a11ce111', + pid: 100, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('has-session')) { + return ''; // exit 0 = exists + } + if (typeof cmd === 'string' && cmd.includes('display-message')) { + return '100\n'; + } + if (typeof cmd === 'string' && cmd.includes('list-sessions')) { + return 'claudeman-a11ce111\n'; + } + return ''; + }); + + const result = await manager.reconcileSessions(); + expect(result.alive).toContain('alive-1'); + expect(result.dead).toHaveLength(0); + }); + + it('should detect dead sessions', async () => { + manager.registerSession({ + sessionId: 'dead-1', + muxName: 'claudeman-dead1111', + pid: 200, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('has-session')) { + throw new Error('session not found'); + } + if (typeof cmd === 'string' && cmd.includes('list-sessions')) { + return ''; // no sessions + } + return ''; + }); + + const result = await manager.reconcileSessions(); + expect(result.dead).toContain('dead-1'); + expect(result.alive).toHaveLength(0); + }); + + it('should discover unknown claudeman sessions', async () => { + // Use hex-only name to pass SAFE_MUX_NAME_PATTERN validation + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('list-sessions')) { + return 'claudeman-abc12345\nmy-other-session\n'; + } + if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('abc12345')) { + return '999\n'; + } + return ''; + }); + + const result = await manager.reconcileSessions(); + expect(result.discovered).toHaveLength(1); + expect(result.discovered[0]).toBe('restored-abc12345'); + }); + + it('should not discover non-claudeman sessions', async () => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('list-sessions')) { + return 'my-tmux-session\n'; + } + return ''; + }); + + const result = await manager.reconcileSessions(); + expect(result.discovered).toHaveLength(0); + }); + }); + + describe('metadata operations', () => { + beforeEach(() => { + manager.registerSession({ + sessionId: 'meta-test', + muxName: 'claudeman-ae1a1234', + pid: 300, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + }); + + it('should update session name', () => { + const result = manager.updateSessionName('meta-test', 'My Session'); + expect(result).toBe(true); + expect(manager.getSession('meta-test')?.name).toBe('My Session'); + }); + + it('should return false for unknown session name update', () => { + const result = manager.updateSessionName('nonexistent', 'Name'); + expect(result).toBe(false); + }); + + it('should set attached status', () => { + manager.setAttached('meta-test', true); + expect(manager.getSession('meta-test')?.attached).toBe(true); + manager.setAttached('meta-test', false); + expect(manager.getSession('meta-test')?.attached).toBe(false); + }); + + it('should update respawn config', () => { + const config = { enabled: true, idleTimeoutMs: 5000, updatePrompt: 'test', interStepDelayMs: 1000, sendClear: true, sendInit: true }; + manager.updateRespawnConfig('meta-test', config); + expect(manager.getSession('meta-test')?.respawnConfig).toEqual(config); + }); + + it('should clear respawn config', () => { + manager.updateRespawnConfig('meta-test', { enabled: true, idleTimeoutMs: 5000, updatePrompt: 'test', interStepDelayMs: 1000, sendClear: true, sendInit: true }); + manager.clearRespawnConfig('meta-test'); + expect(manager.getSession('meta-test')?.respawnConfig).toBeUndefined(); + }); + + it('should update ralph enabled', () => { + manager.updateRalphEnabled('meta-test', true); + expect(manager.getSession('meta-test')?.ralphEnabled).toBe(true); + }); + }); + + describe('getSessions', () => { + it('should return all registered sessions', () => { + manager.registerSession({ + sessionId: 's1', + muxName: 'claudeman-51111111', + pid: 1, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + manager.registerSession({ + sessionId: 's2', + muxName: 'claudeman-52222222', + pid: 2, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'shell', + attached: true, + }); + + const sessions = manager.getSessions(); + expect(sessions).toHaveLength(2); + expect(sessions.map(s => s.sessionId)).toContain('s1'); + expect(sessions.map(s => s.sessionId)).toContain('s2'); + }); + }); + + describe('stats collection', () => { + it('should start and stop stats collection', () => { + manager.startStatsCollection(60000); + // No error thrown + manager.stopStatsCollection(); + // No error thrown + }); + }); +});