chore: bump version to 0.1481

This commit is contained in:
arkon
2026-02-12 10:29:57 +01:00
parent 8a85064748
commit 65f81667d0
15 changed files with 3218 additions and 144 deletions
+36 -17
View File
@@ -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 <name> # Attach (Ctrl+A D to detach)
tmux list-sessions # List tmux sessions
tmux attach-session -t <name> # 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
+1 -1
View File
@@ -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",
+659
View File
@@ -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 <number> 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 "$@"
+505
View File
@@ -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<CACHED_COUNT; i++)); do
draw_row $i $selected
done
draw_footer
}
# Update just the selection (fast, no flicker)
update_selection() {
local old_sel=$1
local new_sel=$2
local header_lines=7 # Lines before session list
# Move to old selection, clear line, redraw as unselected
tput cup $((header_lines + old_sel)) 0
tput el # Clear to end of line
draw_row $old_sel -1 1 # -1 means not selected, 1 means no newline
# Move to new selection, clear line, redraw as selected
tput cup $((header_lines + new_sel)) 0
tput el # Clear to end of line
draw_row $new_sel $new_sel 1 # selected, no newline
}
# Show session info
show_info() {
local idx=$1
local now=$(date +%s)
local name=$(get_session_field $idx "name")
local session_id=$(get_session_field $idx "sessionId")
local mux_name=$(get_session_field $idx "muxName")
local pid=$(get_session_field $idx "pid")
local created_at=$(get_session_field $idx "createdAt")
local mode=$(get_session_field $idx "mode")
local working_dir=$(get_session_field $idx "workingDir")
local created_s=$((created_at / 1000))
local elapsed=$((now - created_s))
local started=$(date -d "@$created_s" '+%Y-%m-%d %H:%M:%S' 2>/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<CACHED_COUNT; i++)); do
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="alive"
check_alive "$mux_name" || status="dead"
[[ ${#name} -gt 20 ]] && name="${name:0:17}..."
printf "%-4s %-22s %-14s %-10s %-8s\n" "[$((i+1))]" "$name" "$running" "$status" "$mode"
done
}
# Usage
usage() {
cat << EOF
Claudeman Tmux Session Manager
Reads from: $SESSIONS_FILE
Usage: $0 [command]
Commands:
(none) Interactive mode with arrow key navigation
list, ls List all sessions
attach N Attach to session #N
kill N Kill session #N (or N,M or N-M)
kill-all Kill all sessions
info N Show session #N details
help Show this help
Interactive Controls:
↑/↓ or j/k Navigate
Enter Attach to selected session
d Delete selected session
D Delete ALL sessions
i Show session info
q/Esc Quit
Examples:
$0 # Interactive mode
$0 list # List sessions
$0 attach 1 # Attach to session 1
$0 kill 2,3 # Kill sessions 2 and 3
EOF
}
# Main
main() {
check_dependencies
case "${1:-}" in
"") interactive_menu ;;
list|ls) quick_list ;;
attach)
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; 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 <N|N,M|N-M>"; 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 <N>"; exit 1; }
force_refresh
show_info $(($2-1))
;;
help|--help|-h) usage ;;
*) echo "Unknown: $1"; usage; exit 1 ;;
esac
}
main "$@"
+59
View File
@@ -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'
);
}
+168
View File
@@ -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<MuxSession>;
/**
* Kill a session and all its child processes.
* Uses a multi-strategy approach (children → process group → mux kill → SIGKILL).
*/
killSession(sessionId: string): Promise<boolean>;
/** 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<MuxSessionWithStats[]>;
/** Get process stats for a single session */
getProcessStats(sessionId: string): Promise<ProcessStats | null>;
// ========== 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;
}
+6 -6
View File
@@ -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<Session> = 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',
});
+96 -1
View File
@@ -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<string, ScreenSession> = 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<MuxSession> {
const screen = await this.createScreen(sessionId, workingDir, mode, name, niceConfig);
return this.screenToMux(screen);
}
async killSession(sessionId: string): Promise<boolean> {
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<MuxSessionWithStats[]> {
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();
}
}
+90 -74
View File
@@ -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
}
}
+888
View File
@@ -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<string, MuxSession> = 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<MuxSession> {
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<boolean> {
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<boolean> {
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<ProcessStats | null> {
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<MuxSessionWithStats[]> {
const sessions = Array.from(this.sessions.values());
if (sessions.length === 0) {
return [];
}
const sessionPids = sessions.map(s => s.pid);
const statsMap = new Map<number, ProcessStats>();
try {
// Step 1: Get descendant PIDs
const descendantMap = new Map<number, number[]>();
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<number>(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<number, { rss: number; cpu: number }>();
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, "'\\''") + "'";
}
+6
View File
@@ -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.)
+50 -40
View File
@@ -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<string, string> = 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<void> {
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()) {
+164
View File
@@ -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');
});
});
});
+103 -5
View File
@@ -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<string>();
/** Track active tmux sessions created during tests */
const activeTestTmuxSessions = new Set<string>();
/** Track Claude PIDs spawned by tests (for cleanup) */
const activeTestClaudePids = new Set<number>();
@@ -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<string>();
// 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<string>();
/** Tmux sessions that existed before tests started (never killed by cleanup) */
const preExistingTmuxSessions = new Set<string>();
/**
* 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,
};
+387
View File
@@ -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
});
});
});