mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1538
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
# PROTECTED SCREEN SESSIONS - DO NOT KILL
|
||||
# Captured at 2026-02-12 before agent-teams implementation
|
||||
# These are live Claudeman sessions running on port 3000
|
||||
685273.claudeman-a5929405
|
||||
684663.claudeman-07a604fb
|
||||
684529.claudeman-a55c7db2
|
||||
# User-specified protected sessions (NEVER kill these):
|
||||
# w1-claudeman, w2, w3-claudeman
|
||||
# NEVER run tests on port 3000 (live dev server)
|
||||
@@ -35,7 +35,7 @@ When user says "COM":
|
||||
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
||||
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
||||
|
||||
**Version**: 0.1537 (must match `package.json` for npm publish)
|
||||
**Version**: 0.1538 (must match `package.json` for npm publish)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -78,7 +78,7 @@ journalctl --user -u claudeman-web -f
|
||||
## Common Gotchas
|
||||
|
||||
- **`npm run dev` is NOT the web server** — it shows CLI help. Use `npx tsx src/index.ts web`
|
||||
- **Single-line prompts only** — `writeViaScreen()` sends text and Enter separately; multi-line breaks Ink
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink
|
||||
- **Don't kill tmux sessions blindly** — Check `$CLAUDEMAN_TMUX` first; you might be inside one
|
||||
- **Never run full test suite** — `npx vitest run` spawns/kills tmux sessions and will crash your Claudeman session. Run individual test files only.
|
||||
|
||||
@@ -98,7 +98,6 @@ journalctl --user -u claudeman-web -f
|
||||
| `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type |
|
||||
| `src/mux-factory.ts` | Create tmux multiplexer (`CLAUDEMAN_MUX` override for legacy screen) |
|
||||
| `src/tmux-manager.ts` | tmux session management |
|
||||
| `src/screen-manager.ts` | GNU screen fallback (deprecated) |
|
||||
| `src/session-manager.ts` | Session lifecycle, cleanup |
|
||||
| `src/state-store.ts` | State persistence to `~/.claudeman/state.json` |
|
||||
| `src/respawn-controller.ts` | State machine for autonomous cycling |
|
||||
@@ -163,9 +162,9 @@ journalctl --user -u claudeman-web -f
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Input to sessions**: Use `session.writeViaScreen()` for programmatic input (respawn, auto-compact). Uses tmux `send-keys -l` (literal text) + `send-keys Enter`. All prompts must be single-line.
|
||||
**Input to sessions**: Use `session.writeViaMux()` for programmatic input (respawn, auto-compact). Uses tmux `send-keys -l` (literal text) + `send-keys Enter`. All prompts must be single-line.
|
||||
|
||||
**Terminal multiplexer**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts the backend. `createMultiplexer()` from `src/mux-factory.ts` creates the tmux backend. Legacy screen fallback exists via `CLAUDEMAN_MUX=screen` but is deprecated.
|
||||
**Terminal multiplexer**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts the backend. `createMultiplexer()` from `src/mux-factory.ts` creates the tmux backend.
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
@@ -337,8 +336,6 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
|
||||
| `scripts/ralph-wizard-prod.mjs` | Production Ralph wizard with HTTPS support |
|
||||
| `scripts/browser-comparison.mjs` | Compare Playwright, Puppeteer, and Agent-Browser frameworks |
|
||||
| `scripts/ralph-wizard-demo.mjs` | Demo Ralph Loop wizard via visible browser |
|
||||
| `scripts/screen-chooser.sh` | Mobile-friendly Screen session picker (`sc` alias) |
|
||||
| `scripts/screen-manager.sh` | Interactive GNU Screen session manager (deprecated) |
|
||||
| `scripts/test-links-browser.mjs` | Browser test for clickable terminal file links |
|
||||
| `scripts/test-patterns.mjs` | Test file path link detection regex patterns |
|
||||
| `scripts/watch-subagents.ts` | Real-time subagent transcript watcher (list, follow by session/agent ID) |
|
||||
|
||||
@@ -54,13 +54,13 @@ Real-time desktop notifications when sessions need attention — never miss a pe
|
||||
|
||||
### 💾 Persistent Sessions
|
||||
|
||||
Every Claude session runs inside **tmux** (or GNU Screen as fallback) — sessions survive server restarts, network drops, and machine sleep.
|
||||
Every Claude session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep.
|
||||
|
||||
```bash
|
||||
# Your sessions are always recoverable
|
||||
CLAUDEMAN_SCREEN=1
|
||||
CLAUDEMAN_MUX=1
|
||||
CLAUDEMAN_SESSION_ID=abc-123-def
|
||||
CLAUDEMAN_SCREEN_NAME=claudeman-myproject
|
||||
CLAUDEMAN_MUX_NAME=claudeman-myproject
|
||||
```
|
||||
|
||||
- Sessions auto-recover on startup (dual redundancy: `state.json` + `mux-sessions.json`)
|
||||
@@ -322,7 +322,7 @@ npm install -g claudeman
|
||||
|
||||
- Node.js 18+
|
||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code/getting-started) installed
|
||||
- tmux (`apt install tmux` / `brew install tmux`) — preferred; GNU Screen as fallback (`CLAUDEMAN_MUX=screen`)
|
||||
- tmux (`apt install tmux` / `brew install tmux`)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -517,7 +517,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph Persistence["Persistence Layer"]
|
||||
SCR["Mux Manager<br/><small>(tmux/screen)</small>"]
|
||||
SCR["Mux Manager<br/><small>(tmux)</small>"]
|
||||
SS["State Store<br/><small>state.json</small>"]
|
||||
end
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ This means:
|
||||
| **tmux** | `--teammate-mode tmux` | Split panes | tmux installed |
|
||||
| **iTerm2** | Auto-detected | Native split panes | iTerm2 + `it2` CLI |
|
||||
|
||||
**For Claudeman: use `in-process` only.** Claudeman manages its own tmux/screen sessions externally.
|
||||
**For Claudeman: use `in-process` only.** Claudeman manages its own tmux sessions externally.
|
||||
|
||||
**In-process UI elements:**
|
||||
- Status bar: `@main @teammate1 @teammate2 ...` with `shift+↑ to expand`
|
||||
|
||||
@@ -46,7 +46,7 @@ After sending each step (update, clear, init, kickstart), the controller waits f
|
||||
## Idle Detection (Multi-Layer)
|
||||
|
||||
1. **Completion message**: Primary signal - detects "Worked for Xm Xs" time patterns (requires "Worked" prefix to avoid false positives)
|
||||
2. **AI Idle Check** (enabled by default): Spawns a fresh Claude session in a screen to analyze terminal output and provide IDLE/WORKING verdict. Uses `claude-opus-4-5-20251101` by default, sends last 16k chars of terminal buffer. Timeout 90s, cooldown 3min after WORKING. Auto-disables after 3 consecutive errors. The AI prompt is conservative: when in doubt, it answers WORKING.
|
||||
2. **AI Idle Check** (enabled by default): Spawns a fresh Claude session in a tmux session to analyze terminal output and provide IDLE/WORKING verdict. Uses `claude-opus-4-5-20251101` by default, sends last 16k chars of terminal buffer. Timeout 90s, cooldown 3min after WORKING. Auto-disables after 3 consecutive errors. The AI prompt is conservative: when in doubt, it answers WORKING.
|
||||
3. **Output silence**: Confirms idle after `completionConfirmMs` (10s) of no new output
|
||||
4. **Token stability**: Tokens haven't changed
|
||||
5. **Working patterns absent**: No `Thinking`, `Writing`, spinner chars, etc. for at least 8 seconds
|
||||
|
||||
+15
-115
@@ -33,7 +33,7 @@ CLAUDE_SEARCH_PATHS=(
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Color Output (from scripts/screen-manager.sh pattern)
|
||||
# Color Output
|
||||
# ============================================================================
|
||||
|
||||
setup_colors() {
|
||||
@@ -243,10 +243,6 @@ check_tmux() {
|
||||
command -v tmux &>/dev/null
|
||||
}
|
||||
|
||||
check_screen() {
|
||||
command -v screen &>/dev/null
|
||||
}
|
||||
|
||||
check_claude() {
|
||||
# Check PATH first
|
||||
if command -v claude &>/dev/null; then
|
||||
@@ -427,51 +423,6 @@ install_tmux_suse() {
|
||||
run_as_root zypper install -y tmux
|
||||
}
|
||||
|
||||
install_screen_macos() {
|
||||
info "Installing GNU Screen via Homebrew..."
|
||||
|
||||
# macOS has a built-in screen but it's very outdated
|
||||
if command -v screen &>/dev/null; then
|
||||
local builtin_version
|
||||
builtin_version=$(screen --version 2>&1 | head -1 || echo "unknown")
|
||||
if [[ "$builtin_version" == *"Apple"* ]] || [[ ! "$builtin_version" == *"GNU"* ]]; then
|
||||
info "Upgrading from macOS built-in screen to GNU Screen..."
|
||||
fi
|
||||
fi
|
||||
|
||||
brew install screen
|
||||
}
|
||||
|
||||
install_screen_debian() {
|
||||
info "Installing GNU Screen via apt..."
|
||||
ensure_sudo
|
||||
run_as_root apt-get update -qq
|
||||
run_as_root apt-get install -y -qq screen
|
||||
}
|
||||
|
||||
install_screen_fedora() {
|
||||
info "Installing GNU Screen via dnf..."
|
||||
ensure_sudo
|
||||
run_as_root dnf install -y screen
|
||||
}
|
||||
|
||||
install_screen_arch() {
|
||||
info "Installing GNU Screen via pacman..."
|
||||
ensure_sudo
|
||||
run_as_root pacman -Sy --noconfirm screen
|
||||
}
|
||||
|
||||
install_screen_alpine() {
|
||||
info "Installing GNU Screen via apk..."
|
||||
run_as_root apk add --no-cache screen
|
||||
}
|
||||
|
||||
install_screen_suse() {
|
||||
info "Installing GNU Screen via zypper..."
|
||||
ensure_sudo
|
||||
run_as_root zypper install -y screen
|
||||
}
|
||||
|
||||
install_git_macos() {
|
||||
info "Installing Git via Homebrew..."
|
||||
brew install git
|
||||
@@ -642,59 +593,15 @@ setup_sc_alias() {
|
||||
|
||||
if [[ "$shell_name" == "fish" ]]; then
|
||||
echo "" >> "$profile"
|
||||
echo "# Claudeman Screens shortcut" >> "$profile"
|
||||
echo "alias sc='screen-chooser'" >> "$profile"
|
||||
echo "# Claudeman tmux session shortcut" >> "$profile"
|
||||
echo "alias sc='tmux-chooser'" >> "$profile"
|
||||
else
|
||||
echo "" >> "$profile"
|
||||
echo "# Claudeman Screens shortcut" >> "$profile"
|
||||
echo "alias sc='screen-chooser'" >> "$profile"
|
||||
echo "# Claudeman tmux session shortcut" >> "$profile"
|
||||
echo "alias sc='tmux-chooser'" >> "$profile"
|
||||
fi
|
||||
|
||||
info "Added 'sc' alias for screen-chooser"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Screen Configuration
|
||||
# ============================================================================
|
||||
|
||||
setup_screenrc() {
|
||||
local screenrc="$HOME/.screenrc"
|
||||
|
||||
# Check if screenrc already exists and has our config
|
||||
if [[ -f "$screenrc" ]] && grep -q "mousetrack on" "$screenrc" 2>/dev/null; then
|
||||
info "Screen configuration already set up"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Setting up screen configuration..."
|
||||
|
||||
# Backup existing screenrc if it exists
|
||||
if [[ -f "$screenrc" ]]; then
|
||||
cp "$screenrc" "${screenrc}.backup.$(date +%Y%m%d%H%M%S)"
|
||||
info "Backed up existing .screenrc"
|
||||
fi
|
||||
|
||||
# Create or append screen config
|
||||
cat >> "$screenrc" << 'EOF'
|
||||
|
||||
# ============================================================================
|
||||
# Claudeman screen configuration
|
||||
# ============================================================================
|
||||
|
||||
# Enable mouse tracking for scrolling in mobile terminals (Termius, etc.)
|
||||
mousetrack on
|
||||
|
||||
# Increase scrollback buffer (default is 100)
|
||||
defscrollback 10000
|
||||
|
||||
# Disable startup message
|
||||
startup_message off
|
||||
|
||||
# Enable alternate screen (better compatibility)
|
||||
altscreen on
|
||||
EOF
|
||||
|
||||
success "Screen configuration saved to $screenrc"
|
||||
info "Added 'sc' alias for tmux-chooser"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
@@ -849,17 +756,15 @@ main() {
|
||||
die "npm is not available. Please reinstall Node.js."
|
||||
fi
|
||||
|
||||
# Terminal multiplexer (tmux preferred, GNU Screen as fallback)
|
||||
info "Checking terminal multiplexer..."
|
||||
# Terminal multiplexer (tmux required)
|
||||
info "Checking tmux..."
|
||||
if check_tmux; then
|
||||
success "tmux is installed (preferred)"
|
||||
elif check_screen; then
|
||||
success "GNU Screen is installed (fallback — consider installing tmux for better performance)"
|
||||
success "tmux is installed"
|
||||
else
|
||||
if prompt_yes_no "tmux is not installed. Install it now?"; then
|
||||
install_dependency "tmux" "$os" "$distro"
|
||||
else
|
||||
die "A terminal multiplexer (tmux or GNU Screen) is required for session persistence."
|
||||
die "tmux is required for session persistence."
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -943,10 +848,10 @@ main() {
|
||||
ln -sf "$INSTALL_DIR/dist/index.js" "$symlink_dir/claudeman"
|
||||
info "Created symlink: $symlink_dir/claudeman"
|
||||
|
||||
# Install screen-chooser as 'screen-chooser' command
|
||||
if [[ -f "$INSTALL_DIR/scripts/screen-chooser.sh" ]]; then
|
||||
ln -sf "$INSTALL_DIR/scripts/screen-chooser.sh" "$symlink_dir/screen-chooser"
|
||||
info "Created symlink: $symlink_dir/screen-chooser"
|
||||
# Install tmux-chooser as 'tmux-chooser' command
|
||||
if [[ -f "$INSTALL_DIR/scripts/tmux-chooser.sh" ]]; then
|
||||
ln -sf "$INSTALL_DIR/scripts/tmux-chooser.sh" "$symlink_dir/tmux-chooser"
|
||||
info "Created symlink: $symlink_dir/tmux-chooser"
|
||||
# Add 'sc' alias for quick access
|
||||
setup_sc_alias
|
||||
fi
|
||||
@@ -957,11 +862,6 @@ main() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# ========================================================================
|
||||
# Screen Configuration
|
||||
# ========================================================================
|
||||
|
||||
setup_screenrc
|
||||
|
||||
# ========================================================================
|
||||
# Systemd Service (Linux only)
|
||||
@@ -996,7 +896,7 @@ main() {
|
||||
echo ""
|
||||
echo -e " ${BOLD}Mobile Access (Termius/SSH):${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}sc${NC} # Interactive screen session chooser"
|
||||
echo -e " ${CYAN}sc${NC} # Interactive tmux session chooser"
|
||||
echo -e " ${CYAN}sc 2${NC} # Quick attach to session 2"
|
||||
echo -e " ${CYAN}sc -h${NC} # Help"
|
||||
echo ""
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1537",
|
||||
"version": "0.1538",
|
||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,719 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Claudeman Screens - Mobile-friendly Screen Session Chooser
|
||||
# Optimized for iPhone/Termius (portrait ~45 chars, landscape ~95 chars)
|
||||
# ============================================================================
|
||||
#
|
||||
# Design principles:
|
||||
# - Single-digit selection (1-9) for fast thumb typing
|
||||
# - Compact display, no wasted space
|
||||
# - Color-coded status for quick scanning
|
||||
# - Names pulled from Claudeman state.json
|
||||
# - Minimal keystrokes to attach
|
||||
#
|
||||
# Usage:
|
||||
# screen-chooser # Interactive chooser
|
||||
# screen-chooser 1 # Quick attach to session 1
|
||||
# screen-chooser -l # List only (non-interactive)
|
||||
# screen-chooser -h # Help
|
||||
#
|
||||
# Alias (added by installer): alias sc='screen-chooser'
|
||||
# Then: sc (interactive)
|
||||
# sc 2 (attach session 2)
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
CLAUDEMAN_STATE="$HOME/.claudeman/state.json"
|
||||
CLAUDEMAN_SCREENS="$HOME/.claudeman/screens.json"
|
||||
|
||||
# iPhone 17 Pro portrait width (conservative)
|
||||
MAX_WIDTH=44
|
||||
MAX_NAME_LEN=28
|
||||
|
||||
# Page size for pagination (leave room for header/footer)
|
||||
PAGE_SIZE=7
|
||||
|
||||
# Auto-refresh timeout (seconds) - 0 to disable
|
||||
AUTO_REFRESH=60
|
||||
|
||||
# ============================================================================
|
||||
# Icon Detection (Nerd Fonts vs ASCII)
|
||||
# ============================================================================
|
||||
|
||||
# Detect if terminal likely supports nerd fonts
|
||||
# Termius generally doesn't, so default to ASCII
|
||||
detect_icons() {
|
||||
# Check TERM and common indicators
|
||||
if [[ "$TERM_PROGRAM" == "iTerm"* ]] || \
|
||||
[[ "$TERM" == "xterm-kitty" ]] || \
|
||||
[[ -n "$WEZTERM_PANE" ]] || \
|
||||
[[ "$LC_TERMINAL" == "iTerm2" ]]; then
|
||||
ICON_SCREEN=""
|
||||
ICON_ATTACHED="●"
|
||||
ICON_DETACHED="○"
|
||||
ICON_UNKNOWN="◌"
|
||||
else
|
||||
# ASCII fallback for Termius/basic terminals
|
||||
ICON_SCREEN="[S]"
|
||||
ICON_ATTACHED="*"
|
||||
ICON_DETACHED="-"
|
||||
ICON_UNKNOWN="?"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_icons
|
||||
|
||||
# ============================================================================
|
||||
# Colors - ANSI 256 for better Termius compatibility
|
||||
# ============================================================================
|
||||
|
||||
R='\033[0m' # Reset
|
||||
B='\033[1m' # Bold
|
||||
D='\033[2m' # Dim
|
||||
GREEN='\033[38;5;82m'
|
||||
YELLOW='\033[38;5;220m'
|
||||
BLUE='\033[38;5;75m'
|
||||
CYAN='\033[38;5;87m'
|
||||
RED='\033[38;5;203m'
|
||||
GRAY='\033[38;5;245m'
|
||||
WHITE='\033[38;5;255m'
|
||||
BG_SEL='\033[48;5;236m' # Selection background
|
||||
|
||||
# ============================================================================
|
||||
# Utilities
|
||||
# ============================================================================
|
||||
|
||||
# Truncate string with ellipsis, keeping end if it looks like a path
|
||||
truncate() {
|
||||
local str="$1"
|
||||
local max="$2"
|
||||
local len=${#str}
|
||||
|
||||
if [ "$len" -le "$max" ]; then
|
||||
echo "$str"
|
||||
return
|
||||
fi
|
||||
|
||||
# If it's a path-like string, keep the end
|
||||
if [[ "$str" == *"/"* ]]; then
|
||||
echo "..${str: -$((max-2))}"
|
||||
else
|
||||
echo "${str:0:$((max-1))}…"
|
||||
fi
|
||||
}
|
||||
|
||||
# Find full session ID from short ID (screen names use first 8 chars)
|
||||
find_full_session_id() {
|
||||
local short_id="$1"
|
||||
|
||||
# Try state.json first
|
||||
if [ -f "$CLAUDEMAN_STATE" ]; then
|
||||
local full_id
|
||||
full_id=$(jq -r --arg short "$short_id" '
|
||||
.sessions | keys[] | select(startswith($short))
|
||||
' "$CLAUDEMAN_STATE" 2>/dev/null | head -1)
|
||||
if [ -n "$full_id" ]; then
|
||||
echo "$full_id"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try screens.json array
|
||||
if [ -f "$CLAUDEMAN_SCREENS" ]; then
|
||||
local full_id
|
||||
full_id=$(jq -r --arg short "$short_id" '
|
||||
.[] | select(.sessionId | startswith($short)) | .sessionId
|
||||
' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1)
|
||||
if [ -n "$full_id" ]; then
|
||||
echo "$full_id"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$short_id"
|
||||
}
|
||||
|
||||
# Get session name from Claudeman state or screens.json
|
||||
# Falls back to working directory basename if name is empty
|
||||
get_session_name() {
|
||||
local session_id="$1"
|
||||
local name=""
|
||||
local workdir=""
|
||||
|
||||
# First, try screens.json (array format) - it has both name and workingDir
|
||||
if [ -f "$CLAUDEMAN_SCREENS" ]; then
|
||||
local result
|
||||
result=$(jq -r --arg id "$session_id" '
|
||||
.[] | select(.sessionId | startswith($id)) | "\(.name // "")\t\(.workingDir // "")"
|
||||
' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1)
|
||||
if [ -n "$result" ]; then
|
||||
name="${result%% *}"
|
||||
workdir="${result#* }"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If no name yet, try state.json
|
||||
if [ -z "$name" ] && [ -f "$CLAUDEMAN_STATE" ]; then
|
||||
local result
|
||||
result=$(jq -r --arg id "$session_id" '
|
||||
.sessions | to_entries[] | select(.key | startswith($id)) | "\(.value.name // "")\t\(.value.workingDir // "")"
|
||||
' "$CLAUDEMAN_STATE" 2>/dev/null | head -1)
|
||||
if [ -n "$result" ]; then
|
||||
name="${result%% *}"
|
||||
[ -z "$workdir" ] && workdir="${result#* }"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If we have a name, use it
|
||||
if [ -n "$name" ]; then
|
||||
echo "$name"
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to working directory basename
|
||||
if [ -n "$workdir" ]; then
|
||||
echo "${workdir##*/}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Last resort: use short ID
|
||||
echo "${session_id:0:8}"
|
||||
}
|
||||
|
||||
# Get working directory from state or screens.json
|
||||
get_working_dir() {
|
||||
local session_id="$1"
|
||||
|
||||
# Try screens.json first (array format)
|
||||
if [ -f "$CLAUDEMAN_SCREENS" ]; then
|
||||
local dir
|
||||
dir=$(jq -r --arg id "$session_id" '
|
||||
.[] | select(.sessionId | startswith($id)) | .workingDir // empty
|
||||
' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1)
|
||||
if [ -n "$dir" ] && [ "$dir" != "null" ]; then
|
||||
echo "${dir/#$HOME/~}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try state.json
|
||||
if [ -f "$CLAUDEMAN_STATE" ]; then
|
||||
local dir
|
||||
dir=$(jq -r --arg id "$session_id" '
|
||||
.sessions | to_entries[] | select(.key | startswith($id)) | .value.workingDir // empty
|
||||
' "$CLAUDEMAN_STATE" 2>/dev/null | head -1)
|
||||
if [ -n "$dir" ] && [ "$dir" != "null" ]; then
|
||||
echo "${dir/#$HOME/~}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Get token count from state
|
||||
get_tokens() {
|
||||
local session_id="$1"
|
||||
|
||||
if [ -f "$CLAUDEMAN_STATE" ]; then
|
||||
local tokens
|
||||
tokens=$(jq -r --arg id "$session_id" '
|
||||
.sessions | to_entries[] | select(.key | startswith($id)) |
|
||||
((.value.inputTokens // 0) + (.value.outputTokens // 0))
|
||||
' "$CLAUDEMAN_STATE" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$tokens" ] && [ "$tokens" != "null" ] && [ "$tokens" -gt 0 ] 2>/dev/null; then
|
||||
if [ "$tokens" -gt 1000 ]; then
|
||||
echo "$((tokens / 1000))k"
|
||||
else
|
||||
echo "${tokens}"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Get respawn status from screens.json (array format)
|
||||
get_respawn_status() {
|
||||
local session_id="$1"
|
||||
|
||||
if [ -f "$CLAUDEMAN_SCREENS" ]; then
|
||||
local respawn_enabled
|
||||
respawn_enabled=$(jq -r --arg id "$session_id" '
|
||||
.[] | select(.sessionId | startswith($id)) | .respawnConfig.enabled // false
|
||||
' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1)
|
||||
|
||||
if [ "$respawn_enabled" = "true" ]; then
|
||||
echo "R" # Respawn active
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Check if jq is available
|
||||
check_deps() {
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo -e "${YELLOW}Note: Install jq for session names${R}"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Screen List Parser
|
||||
# ============================================================================
|
||||
|
||||
declare -a SCREEN_PIDS
|
||||
declare -a SCREEN_NAMES
|
||||
declare -a SCREEN_STATES
|
||||
declare -a SESSION_IDS
|
||||
declare -a DISPLAY_NAMES
|
||||
declare -a WORKING_DIRS
|
||||
declare -a TOKEN_COUNTS
|
||||
declare -a RESPAWN_STATUS
|
||||
|
||||
parse_screens() {
|
||||
SCREEN_PIDS=()
|
||||
SCREEN_NAMES=()
|
||||
SCREEN_STATES=()
|
||||
SESSION_IDS=()
|
||||
DISPLAY_NAMES=()
|
||||
WORKING_DIRS=()
|
||||
TOKEN_COUNTS=()
|
||||
RESPAWN_STATUS=()
|
||||
|
||||
local i=0
|
||||
|
||||
# Regex for screen -ls lines: " 12345.claudeman-abc123 (date) (Attached)"
|
||||
# We need to capture: PID, name, and the LAST parenthesized group (state)
|
||||
# Modern screen -ls shows: PID.name (date) (Attached|Detached)
|
||||
local screen_regex='^[[:space:]]*([0-9]+)\.([^[:space:]]+).*\((Attached|Detached|Multi)\)'
|
||||
|
||||
# Parse screen -ls output
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ $screen_regex ]]; then
|
||||
local pid="${BASH_REMATCH[1]}"
|
||||
local name="${BASH_REMATCH[2]}"
|
||||
local state="${BASH_REMATCH[3]}"
|
||||
|
||||
SCREEN_PIDS+=("$pid")
|
||||
SCREEN_NAMES+=("$name")
|
||||
SCREEN_STATES+=("$state")
|
||||
|
||||
# Extract session ID from claudeman screen name
|
||||
local session_id=""
|
||||
local cm_regex='^claudeman-(.+)$'
|
||||
if [[ "$name" =~ $cm_regex ]]; then
|
||||
session_id="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
SESSION_IDS+=("$session_id")
|
||||
|
||||
# Get display name and metadata
|
||||
if [ -n "$session_id" ]; then
|
||||
DISPLAY_NAMES+=("$(get_session_name "$session_id")")
|
||||
WORKING_DIRS+=("$(get_working_dir "$session_id")")
|
||||
TOKEN_COUNTS+=("$(get_tokens "$session_id")")
|
||||
RESPAWN_STATUS+=("$(get_respawn_status "$session_id")")
|
||||
else
|
||||
DISPLAY_NAMES+=("$name")
|
||||
WORKING_DIRS+=("")
|
||||
TOKEN_COUNTS+=("")
|
||||
RESPAWN_STATUS+=("")
|
||||
fi
|
||||
|
||||
i=$((i + 1))
|
||||
fi
|
||||
done < <(screen -ls 2>/dev/null || true)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Display Functions
|
||||
# ============================================================================
|
||||
|
||||
clear_screen() {
|
||||
printf '\033[2J\033[H'
|
||||
}
|
||||
|
||||
# Print header
|
||||
print_header() {
|
||||
local count=${#SCREEN_PIDS[@]}
|
||||
echo -e "${B}${CYAN}Claudeman Screens${R} ${D}($count)${R}"
|
||||
echo -e "${D}$(printf '%.0s─' {1..32})${R}"
|
||||
}
|
||||
|
||||
# Print a session entry
|
||||
print_entry() {
|
||||
local idx="$1"
|
||||
local num=$((idx + 1))
|
||||
local name="${DISPLAY_NAMES[$idx]}"
|
||||
local state="${SCREEN_STATES[$idx]}"
|
||||
local dir="${WORKING_DIRS[$idx]}"
|
||||
local tokens="${TOKEN_COUNTS[$idx]}"
|
||||
local respawn="${RESPAWN_STATUS[$idx]}"
|
||||
|
||||
# Truncate name (leave room for indicators)
|
||||
local name_max=$MAX_NAME_LEN
|
||||
[ -n "$respawn" ] && name_max=$((name_max - 2))
|
||||
[ -n "$tokens" ] && name_max=$((name_max - 4))
|
||||
name=$(truncate "$name" $name_max)
|
||||
|
||||
# Status indicator and color
|
||||
local status_icon status_color
|
||||
if [[ "$state" == *"Attached"* ]]; then
|
||||
status_icon="$ICON_ATTACHED"
|
||||
status_color="$GREEN"
|
||||
elif [[ "$state" == *"Detached"* ]]; then
|
||||
status_icon="$ICON_DETACHED"
|
||||
status_color="$GRAY"
|
||||
else
|
||||
status_icon="$ICON_UNKNOWN"
|
||||
status_color="$YELLOW"
|
||||
fi
|
||||
|
||||
# Build the line
|
||||
# Format: "1) name ○ R 45k"
|
||||
local num_str="${B}${WHITE}${num})${R}"
|
||||
local name_str="${B}${WHITE}${name}${R}"
|
||||
local status_str="${status_color}${status_icon}${R}"
|
||||
|
||||
# Respawn indicator (green R if active)
|
||||
local respawn_str=""
|
||||
if [ -n "$respawn" ]; then
|
||||
respawn_str=" ${GREEN}${respawn}${R}"
|
||||
fi
|
||||
|
||||
# Token display
|
||||
local token_str=""
|
||||
if [ -n "$tokens" ]; then
|
||||
token_str=" ${D}${tokens}${R}"
|
||||
fi
|
||||
|
||||
echo -e " ${num_str} ${name_str} ${status_str}${respawn_str}${token_str}"
|
||||
|
||||
# Show directory on second line if present (dimmed, indented)
|
||||
if [ -n "$dir" ]; then
|
||||
dir=$(truncate "$dir" $((MAX_NAME_LEN - 2)))
|
||||
echo -e " ${D}${dir}${R}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Print footer with commands
|
||||
print_footer() {
|
||||
local page="$1"
|
||||
local total_pages="$2"
|
||||
|
||||
echo ""
|
||||
echo -e "${D}────────────────────────────────${R}"
|
||||
|
||||
# Pagination indicator
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
echo -e " ${D}Page $((page+1))/$total_pages${R} ${GRAY}[${WHITE}n${GRAY}]ext [${WHITE}p${GRAY}]rev${R}"
|
||||
fi
|
||||
|
||||
# Commands - most used first, thumb-friendly
|
||||
echo -e " ${GRAY}[${WHITE}1-9${GRAY}]attach [${WHITE}r${GRAY}]efresh [${WHITE}q${GRAY}]uit${R}"
|
||||
}
|
||||
|
||||
# Print no screens message
|
||||
print_no_screens() {
|
||||
clear_screen
|
||||
echo -e "${B}${CYAN}Claudeman Screens${R}"
|
||||
echo -e "${D}$(printf '%.0s─' {1..32})${R}"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}No screen sessions found${R}"
|
||||
echo ""
|
||||
echo -e " ${D}Start one with:${R}"
|
||||
echo -e " ${WHITE}claudeman web${R}"
|
||||
echo ""
|
||||
echo -e "${D}$(printf '%.0s─' {1..32})${R}"
|
||||
echo -e " ${GRAY}[${WHITE}r${GRAY}]efresh [${WHITE}q${GRAY}]uit${R}"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Display Loop
|
||||
# ============================================================================
|
||||
|
||||
current_page=0
|
||||
|
||||
render() {
|
||||
clear_screen
|
||||
parse_screens
|
||||
|
||||
local count=${#SCREEN_PIDS[@]}
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
print_no_screens
|
||||
return
|
||||
fi
|
||||
|
||||
local total_pages=$(( (count + PAGE_SIZE - 1) / PAGE_SIZE ))
|
||||
|
||||
# Clamp page
|
||||
if [ "$current_page" -ge "$total_pages" ]; then
|
||||
current_page=$((total_pages - 1))
|
||||
fi
|
||||
if [ "$current_page" -lt 0 ]; then
|
||||
current_page=0
|
||||
fi
|
||||
|
||||
local start=$((current_page * PAGE_SIZE))
|
||||
local end=$((start + PAGE_SIZE))
|
||||
if [ "$end" -gt "$count" ]; then
|
||||
end=$count
|
||||
fi
|
||||
|
||||
print_header
|
||||
echo ""
|
||||
|
||||
for ((i = start; i < end; i++)); do
|
||||
print_entry $i
|
||||
done
|
||||
|
||||
print_footer $current_page $total_pages
|
||||
}
|
||||
|
||||
# Attach to a screen
|
||||
attach_screen() {
|
||||
local idx="$1"
|
||||
local pid="${SCREEN_PIDS[$idx]}"
|
||||
local name="${SCREEN_NAMES[$idx]}"
|
||||
local state="${SCREEN_STATES[$idx]}"
|
||||
|
||||
if [ -z "$pid" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
clear_screen
|
||||
echo -e "${GREEN}Attaching to ${B}${DISPLAY_NAMES[$idx]}${R}${GREEN}...${R}"
|
||||
echo -e "${D}(Ctrl+A D to detach)${R}"
|
||||
sleep 0.3
|
||||
|
||||
# IMPORTANT: Claudeman attaches to screen via PTY with -x for streaming.
|
||||
# Using -rd here would detach Claudeman and break terminal output.
|
||||
#
|
||||
# For mobile access without squeezing the desktop view:
|
||||
# - Use the web UI from mobile Safari (responsive, works on iPhone)
|
||||
# - The web UI at http://yourserver:3000 works great on mobile browsers
|
||||
#
|
||||
# If you attach via screen -x, the terminal will resize to the smallest
|
||||
# attached client (your mobile). Use Ctrl+A D to detach when done.
|
||||
if [[ "$state" == "Attached" ]] || [[ "$state" == "Multi" ]]; then
|
||||
screen -x "$pid.$name"
|
||||
else
|
||||
screen -r "$pid.$name"
|
||||
fi
|
||||
|
||||
# After detach, return to chooser
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Input Handler
|
||||
# ============================================================================
|
||||
|
||||
handle_input() {
|
||||
local key="$1"
|
||||
local count=${#SCREEN_PIDS[@]}
|
||||
local total_pages=$(( (count + PAGE_SIZE - 1) / PAGE_SIZE ))
|
||||
|
||||
case "$key" in
|
||||
# Number selection (1-9)
|
||||
[1-9])
|
||||
local idx=$((key - 1))
|
||||
if [ "$idx" -lt "$count" ]; then
|
||||
attach_screen "$idx"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
|
||||
# Escape sequence (arrow keys)
|
||||
$'\e')
|
||||
# Read the rest of the escape sequence
|
||||
read -rsn2 -t 0.1 seq 2>/dev/null || true
|
||||
case "$seq" in
|
||||
'[A'|'[D') # Up or Left arrow
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
current_page=$(( (current_page - 1 + total_pages) % total_pages ))
|
||||
fi
|
||||
;;
|
||||
'[B'|'[C') # Down or Right arrow
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
current_page=$(( (current_page + 1) % total_pages ))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
|
||||
# Navigation (vim-style and standard)
|
||||
n|N|j|J)
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
current_page=$(( (current_page + 1) % total_pages ))
|
||||
fi
|
||||
;;
|
||||
|
||||
p|P|k|K)
|
||||
if [ "$total_pages" -gt 1 ]; then
|
||||
current_page=$(( (current_page - 1 + total_pages) % total_pages ))
|
||||
fi
|
||||
;;
|
||||
|
||||
# Refresh
|
||||
r|R)
|
||||
# Just re-render
|
||||
;;
|
||||
|
||||
# Quit
|
||||
q|Q)
|
||||
clear_screen
|
||||
exit 0
|
||||
;;
|
||||
|
||||
# Enter with no selection - attach first if only one
|
||||
'')
|
||||
if [ "$count" -eq 1 ]; then
|
||||
attach_screen 0
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# List Mode (non-interactive, for scripting)
|
||||
# ============================================================================
|
||||
|
||||
list_mode() {
|
||||
parse_screens
|
||||
local count=${#SCREEN_PIDS[@]}
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "No screen sessions"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for ((i = 0; i < count; i++)); do
|
||||
local num=$((i + 1))
|
||||
local name="${DISPLAY_NAMES[$i]}"
|
||||
local state="${SCREEN_STATES[$i]}"
|
||||
local respawn="${RESPAWN_STATUS[$i]}"
|
||||
local indicator="-"
|
||||
[[ "$state" == *"Attached"* ]] && indicator="*"
|
||||
[ -n "$respawn" ] && indicator="${indicator}R"
|
||||
|
||||
echo "$num) $name [$indicator]"
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Quick Attach (by number)
|
||||
# ============================================================================
|
||||
|
||||
quick_attach() {
|
||||
local num="$1"
|
||||
parse_screens
|
||||
|
||||
local count=${#SCREEN_PIDS[@]}
|
||||
local idx=$((num - 1))
|
||||
|
||||
if [ "$idx" -lt 0 ] || [ "$idx" -ge "$count" ]; then
|
||||
echo -e "${RED}Invalid session: $num${R}"
|
||||
echo "Available: 1-$count"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
attach_screen "$idx"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Help
|
||||
# ============================================================================
|
||||
|
||||
show_help() {
|
||||
cat << 'EOF'
|
||||
Claudeman Screens - Mobile-friendly Screen Session Chooser
|
||||
|
||||
USAGE:
|
||||
sc Interactive chooser
|
||||
sc <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 screen: Ctrl+A D
|
||||
- Session names from Claudeman state
|
||||
- Optimized for Termius/iPhone
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
||||
main() {
|
||||
# Argument parsing
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-l|--list)
|
||||
list_mode
|
||||
exit 0
|
||||
;;
|
||||
[1-9]|[1-9][0-9])
|
||||
# Quick attach by number
|
||||
quick_attach "$1"
|
||||
exit $?
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check dependencies
|
||||
check_deps
|
||||
|
||||
# Initial render
|
||||
render
|
||||
|
||||
# Input loop
|
||||
while true; do
|
||||
# Read single character without enter
|
||||
# -s: silent, -n1: one char, -t: timeout for auto-refresh
|
||||
local timeout_opt=""
|
||||
if [ "$AUTO_REFRESH" -gt 0 ]; then
|
||||
timeout_opt="-t $AUTO_REFRESH"
|
||||
fi
|
||||
|
||||
if read -rsn1 $timeout_opt key 2>/dev/null; then
|
||||
handle_input "$key"
|
||||
fi
|
||||
# Re-render after input or timeout (auto-refresh)
|
||||
render
|
||||
done
|
||||
}
|
||||
|
||||
# Handle Ctrl+C gracefully
|
||||
trap 'clear_screen; exit 0' INT
|
||||
|
||||
# Run
|
||||
main "$@"
|
||||
@@ -1,494 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Claudeman Screen Session Manager
|
||||
# Interactive tool with arrow key navigation
|
||||
# Reads from ~/.claudeman/screens.json
|
||||
#
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m'
|
||||
REVERSE='\033[7m'
|
||||
|
||||
# Use the same path as claudeman (src/screen-manager.ts)
|
||||
SCREENS_FILE="${HOME}/.claudeman/screens.json"
|
||||
|
||||
# Cached data
|
||||
CACHED_JSON=""
|
||||
CACHED_COUNT=0
|
||||
LAST_REFRESH=0
|
||||
REFRESH_INTERVAL=5 # Refresh data every 5 seconds
|
||||
|
||||
# Cleanup on exit
|
||||
cleanup() {
|
||||
tput cnorm 2>/dev/null
|
||||
stty echo 2>/dev/null
|
||||
echo ""
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies() {
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo -e "${RED}Error: 'jq' is required. Install with: sudo apt install jq${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v screen &> /dev/null; then
|
||||
echo -e "${RED}Error: 'screen' is required. Install with: sudo apt install screen${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Refresh cached data if needed
|
||||
refresh_cache() {
|
||||
local now=$(date +%s)
|
||||
if [[ $((now - LAST_REFRESH)) -ge $REFRESH_INTERVAL ]] || [[ -z "$CACHED_JSON" ]]; then
|
||||
if [[ -f "$SCREENS_FILE" ]]; then
|
||||
CACHED_JSON=$(cat "$SCREENS_FILE")
|
||||
else
|
||||
CACHED_JSON="[]"
|
||||
fi
|
||||
CACHED_COUNT=$(echo "$CACHED_JSON" | jq 'length')
|
||||
LAST_REFRESH=$now
|
||||
fi
|
||||
}
|
||||
|
||||
# Force refresh
|
||||
force_refresh() {
|
||||
LAST_REFRESH=0
|
||||
refresh_cache
|
||||
}
|
||||
|
||||
get_session_field() {
|
||||
local index=$1 field=$2
|
||||
echo "$CACHED_JSON" | jq -r ".[$index].$field // \"unknown\""
|
||||
}
|
||||
|
||||
# Format duration
|
||||
format_duration() {
|
||||
local s=$1 d=$((s/86400)) h=$(((s%86400)/3600)) m=$(((s%3600)/60))
|
||||
if [[ $d -gt 0 ]]; then printf "%dd %dh" $d $h
|
||||
elif [[ $h -gt 0 ]]; then printf "%dh %dm" $h $m
|
||||
elif [[ $m -gt 0 ]]; then printf "%dm" $m
|
||||
else printf "%ds" $s; fi
|
||||
}
|
||||
|
||||
# Check if screen alive (cached per draw cycle)
|
||||
declare -A ALIVE_CACHE
|
||||
check_alive() {
|
||||
local screen_name=$1
|
||||
if [[ -z "${ALIVE_CACHE[$screen_name]+x}" ]]; then
|
||||
if screen -ls 2>/dev/null | grep -q "$screen_name"; then
|
||||
ALIVE_CACHE[$screen_name]=1
|
||||
else
|
||||
ALIVE_CACHE[$screen_name]=0
|
||||
fi
|
||||
fi
|
||||
[[ "${ALIVE_CACHE[$screen_name]}" -eq 1 ]]
|
||||
}
|
||||
|
||||
clear_alive_cache() {
|
||||
ALIVE_CACHE=()
|
||||
}
|
||||
|
||||
# Kill session by index
|
||||
kill_session() {
|
||||
local idx=$1
|
||||
local screen_name=$(get_session_field $idx "screenName")
|
||||
local pid=$(get_session_field $idx "pid")
|
||||
|
||||
pkill -TERM -P $pid 2>/dev/null
|
||||
kill -TERM -$pid 2>/dev/null
|
||||
screen -S "$screen_name" -X quit 2>/dev/null
|
||||
kill -KILL $pid 2>/dev/null
|
||||
|
||||
# Remove from JSON
|
||||
CACHED_JSON=$(echo "$CACHED_JSON" | jq "del(.[$idx])")
|
||||
echo "$CACHED_JSON" > "$SCREENS_FILE"
|
||||
CACHED_COUNT=$(echo "$CACHED_JSON" | jq 'length')
|
||||
clear_alive_cache
|
||||
}
|
||||
|
||||
# Draw header (only once)
|
||||
draw_header() {
|
||||
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ Claudeman Screen Session Manager ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ ${DIM}Press q or Esc to exit${CYAN} ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Draw a single session row (no_newline=1 for partial updates)
|
||||
draw_row() {
|
||||
local i=$1
|
||||
local selected=$2
|
||||
local no_newline=${3:-0}
|
||||
local now=$(date +%s)
|
||||
|
||||
local name=$(echo "$CACHED_JSON" | jq -r ".[$i].name // \"unnamed\"")
|
||||
local screen_name=$(echo "$CACHED_JSON" | jq -r ".[$i].screenName")
|
||||
local created_at=$(echo "$CACHED_JSON" | jq -r ".[$i].createdAt")
|
||||
local mode=$(echo "$CACHED_JSON" | jq -r ".[$i].mode")
|
||||
|
||||
local created_s=$((created_at / 1000))
|
||||
local elapsed=$((now - created_s))
|
||||
local running=$(format_duration $elapsed)
|
||||
|
||||
local status status_color
|
||||
if check_alive "$screen_name"; then
|
||||
status="● alive"
|
||||
status_color="${GREEN}"
|
||||
else
|
||||
status="○ dead"
|
||||
status_color="${RED}"
|
||||
fi
|
||||
|
||||
[[ ${#name} -gt 20 ]] && name="${name:0:17}..."
|
||||
|
||||
local line_end=$'\n'
|
||||
[[ $no_newline -eq 1 ]] && line_end=""
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf " ${REVERSE}${BOLD}▶ %-20s %-14s ${status_color}%-12s${NC}${REVERSE} %-8s ${NC}%s" \
|
||||
"$name" "$running" "$status" "$mode" "$line_end"
|
||||
else
|
||||
printf " %-20s %-14s ${status_color}%-12s${NC} %-8s%s" \
|
||||
"$name" "$running" "$status" "$mode" "$line_end"
|
||||
fi
|
||||
}
|
||||
|
||||
# Draw footer
|
||||
draw_footer() {
|
||||
echo ""
|
||||
echo -e " ${DIM}──────────────────────────────────────────────────────────────${NC}"
|
||||
echo -e " ${GREEN}↑/↓${NC} Navigate ${GREEN}Enter${NC} Attach ${YELLOW}d${NC} Delete ${RED}D${NC} Delete All ${BLUE}i${NC} Info ${DIM}q${NC} Quit"
|
||||
}
|
||||
|
||||
# Full redraw
|
||||
full_redraw() {
|
||||
local selected=$1
|
||||
clear
|
||||
draw_header
|
||||
|
||||
if [[ $CACHED_COUNT -eq 0 ]]; then
|
||||
echo -e " ${YELLOW}No sessions found${NC}"
|
||||
echo -e " ${DIM}(checking: $SCREENS_FILE)${NC}"
|
||||
echo ""
|
||||
echo -e " ${DIM}Press q to quit${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
printf " ${BOLD}%-22s %-14s %-12s %-8s${NC}\n" "NAME" "RUNNING" "STATUS" "MODE"
|
||||
echo -e " ${DIM}──────────────────────────────────────────────────────────────${NC}"
|
||||
|
||||
for ((i=0; i<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 screen_name=$(get_session_field $idx "screenName")
|
||||
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}Screen:${NC} $screen_name"
|
||||
echo -e " ${BOLD}PID:${NC} $pid"
|
||||
echo -e " ${BOLD}Mode:${NC} $mode"
|
||||
echo -e " ${BOLD}Directory:${NC} $working_dir"
|
||||
echo -e " ${BOLD}Started:${NC} $started"
|
||||
echo -e " ${BOLD}Running:${NC} $(format_duration $elapsed)"
|
||||
|
||||
if check_alive "$screen_name"; then
|
||||
echo -e " ${BOLD}Status:${NC} ${GREEN}● alive${NC}"
|
||||
local mem=$(ps -o rss= -p $pid 2>/dev/null | tr -d ' ')
|
||||
[[ -n "$mem" ]] && echo -e " ${BOLD}Memory:${NC} $(echo "scale=1; $mem/1024" | bc) MB"
|
||||
else
|
||||
echo -e " ${BOLD}Status:${NC} ${RED}○ dead${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${DIM}Press any key to continue...${NC}"
|
||||
read -rsn1
|
||||
}
|
||||
|
||||
# Confirm dialog
|
||||
confirm() {
|
||||
local msg=$1
|
||||
tput sc # Save cursor
|
||||
tput cup $((CACHED_COUNT + 12)) 0
|
||||
echo -en " ${YELLOW}$msg (y/n):${NC} "
|
||||
read -rsn1 answer
|
||||
tput rc # Restore cursor
|
||||
[[ "$answer" == "y" || "$answer" == "Y" ]]
|
||||
}
|
||||
|
||||
# Read single keypress (handles arrow keys)
|
||||
read_key() {
|
||||
local key
|
||||
IFS= read -rsn1 key
|
||||
|
||||
if [[ "$key" == $'\x1b' ]]; then
|
||||
read -rsn2 -t 0.1 key
|
||||
case "$key" in
|
||||
'[A') echo "UP" ;;
|
||||
'[B') echo "DOWN" ;;
|
||||
*) echo "ESC" ;;
|
||||
esac
|
||||
elif [[ "$key" == "" ]]; then
|
||||
echo "ENTER"
|
||||
else
|
||||
echo "$key"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main interactive loop
|
||||
interactive_menu() {
|
||||
local selected=0
|
||||
local need_full_redraw=1
|
||||
|
||||
tput civis # Hide cursor
|
||||
force_refresh
|
||||
clear_alive_cache
|
||||
full_redraw $selected
|
||||
need_full_redraw=0
|
||||
|
||||
while true; do
|
||||
# Adjust selection if out of bounds
|
||||
[[ $selected -ge $CACHED_COUNT ]] && selected=$((CACHED_COUNT > 0 ? CACHED_COUNT - 1 : 0))
|
||||
[[ $selected -lt 0 ]] && selected=0
|
||||
|
||||
# Redraw if needed
|
||||
if [[ $need_full_redraw -eq 1 ]]; then
|
||||
clear_alive_cache
|
||||
full_redraw $selected
|
||||
need_full_redraw=0
|
||||
fi
|
||||
|
||||
local key=$(read_key)
|
||||
local old_selected=$selected
|
||||
|
||||
case "$key" in
|
||||
UP|k)
|
||||
if [[ $selected -gt 0 ]]; then
|
||||
((selected--))
|
||||
[[ $CACHED_COUNT -gt 0 ]] && update_selection $old_selected $selected
|
||||
fi
|
||||
;;
|
||||
DOWN|j)
|
||||
if [[ $selected -lt $((CACHED_COUNT - 1)) ]]; then
|
||||
((selected++))
|
||||
[[ $CACHED_COUNT -gt 0 ]] && update_selection $old_selected $selected
|
||||
fi
|
||||
;;
|
||||
ENTER)
|
||||
if [[ $CACHED_COUNT -gt 0 ]]; then
|
||||
local screen_name=$(get_session_field $selected "screenName")
|
||||
if check_alive "$screen_name"; then
|
||||
tput cnorm
|
||||
clear
|
||||
echo -e "${CYAN}Attaching... (Ctrl+A D to detach)${NC}"
|
||||
sleep 0.3
|
||||
screen -r "$screen_name"
|
||||
tput civis
|
||||
need_full_redraw=1
|
||||
force_refresh
|
||||
else
|
||||
tput cup $((CACHED_COUNT + 12)) 0
|
||||
echo -e " ${RED}Session is dead${NC} "
|
||||
sleep 1
|
||||
need_full_redraw=1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
d|x)
|
||||
if [[ $CACHED_COUNT -gt 0 ]]; then
|
||||
local name=$(get_session_field $selected "name")
|
||||
if confirm "Kill '$name'?"; then
|
||||
kill_session $selected
|
||||
need_full_redraw=1
|
||||
else
|
||||
need_full_redraw=1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
D|X)
|
||||
if [[ $CACHED_COUNT -gt 0 ]]; then
|
||||
if confirm "Kill ALL $CACHED_COUNT sessions?"; then
|
||||
for ((i=CACHED_COUNT-1; i>=0; i--)); do
|
||||
kill_session $i
|
||||
done
|
||||
fi
|
||||
need_full_redraw=1
|
||||
fi
|
||||
;;
|
||||
i)
|
||||
if [[ $CACHED_COUNT -gt 0 ]]; then
|
||||
show_info $selected
|
||||
need_full_redraw=1
|
||||
fi
|
||||
;;
|
||||
q|Q|ESC)
|
||||
clear
|
||||
echo -e "${DIM}Goodbye!${NC}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
tput cnorm
|
||||
}
|
||||
|
||||
# Quick list (non-interactive)
|
||||
quick_list() {
|
||||
force_refresh
|
||||
local now=$(date +%s)
|
||||
|
||||
if [[ $CACHED_COUNT -eq 0 ]]; then
|
||||
echo "No claudeman sessions found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Claudeman Sessions ($SCREENS_FILE):"
|
||||
echo ""
|
||||
printf "%-4s %-22s %-14s %-10s %-8s\n" "#" "NAME" "RUNNING" "STATUS" "MODE"
|
||||
echo "─────────────────────────────────────────────────────────────────"
|
||||
|
||||
for ((i=0; i<CACHED_COUNT; i++)); do
|
||||
local name=$(echo "$CACHED_JSON" | jq -r ".[$i].name // \"unnamed\"")
|
||||
local screen_name=$(echo "$CACHED_JSON" | jq -r ".[$i].screenName")
|
||||
local created_at=$(echo "$CACHED_JSON" | jq -r ".[$i].createdAt")
|
||||
local mode=$(echo "$CACHED_JSON" | jq -r ".[$i].mode")
|
||||
|
||||
local created_s=$((created_at / 1000))
|
||||
local elapsed=$((now - created_s))
|
||||
local running=$(format_duration $elapsed)
|
||||
|
||||
local status="alive"
|
||||
check_alive "$screen_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 Screen Session Manager
|
||||
|
||||
Reads from: $SCREENS_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 screen_name=$(get_session_field $(($2-1)) "screenName")
|
||||
check_alive "$screen_name" && screen -r "$screen_name" || echo "Session dead or not found"
|
||||
;;
|
||||
kill)
|
||||
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill <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 "$@"
|
||||
@@ -32,10 +32,6 @@ set -e
|
||||
CLAUDEMAN_STATE="$HOME/.claudeman/state.json"
|
||||
CLAUDEMAN_SESSIONS="$HOME/.claudeman/mux-sessions.json"
|
||||
|
||||
# Fallback to legacy screens.json
|
||||
if [[ ! -f "$CLAUDEMAN_SESSIONS" ]] && [[ -f "$HOME/.claudeman/screens.json" ]]; then
|
||||
CLAUDEMAN_SESSIONS="$HOME/.claudeman/screens.json"
|
||||
fi
|
||||
|
||||
# iPhone 17 Pro portrait width (conservative)
|
||||
MAX_WIDTH=44
|
||||
|
||||
@@ -20,10 +20,6 @@ REVERSE='\033[7m'
|
||||
# Use the same path as claudeman (src/tmux-manager.ts)
|
||||
SESSIONS_FILE="${HOME}/.claudeman/mux-sessions.json"
|
||||
|
||||
# Fallback to legacy screens.json if mux-sessions.json doesn't exist
|
||||
if [[ ! -f "$SESSIONS_FILE" ]] && [[ -f "${HOME}/.claudeman/screens.json" ]]; then
|
||||
SESSIONS_FILE="${HOME}/.claudeman/screens.json"
|
||||
fi
|
||||
|
||||
# Cached data
|
||||
CACHED_JSON=""
|
||||
|
||||
+28
-37
@@ -11,7 +11,7 @@
|
||||
* - Event emission for state changes
|
||||
*
|
||||
* Subclasses implement:
|
||||
* - `screenNamePrefix`: Prefix for mux session names (e.g., 'claudeman-aicheck-')
|
||||
* - `muxNamePrefix`: Prefix for mux session names (e.g., 'claudeman-aicheck-')
|
||||
* - `doneMarker`: Completion marker in output file (e.g., '__AICHECK_DONE__')
|
||||
* - `tempFilePrefix`: Prefix for temp files (e.g., 'claudeman-aicheck')
|
||||
* - `logPrefix`: Prefix for log messages (e.g., '[AiIdleChecker]')
|
||||
@@ -45,12 +45,12 @@ function isValidModelName(model: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a screen name is safe for shell use.
|
||||
* Screen names should only contain alphanumeric characters, hyphens, and underscores.
|
||||
* Validates that a mux session name is safe for shell use.
|
||||
* Names should only contain alphanumeric characters, hyphens, and underscores.
|
||||
*/
|
||||
function isValidScreenName(screenName: string): boolean {
|
||||
if (!screenName || typeof screenName !== 'string') return false;
|
||||
return /^[a-zA-Z0-9_-]+$/.test(screenName) && screenName.length <= 100;
|
||||
function isValidMuxName(muxName: string): boolean {
|
||||
if (!muxName || typeof muxName !== 'string') return false;
|
||||
return /^[a-zA-Z0-9_-]+$/.test(muxName) && muxName.length <= 100;
|
||||
}
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -143,7 +143,7 @@ export abstract class AiCheckerBase<
|
||||
protected disabledReason: string | null = null;
|
||||
|
||||
// Active check state
|
||||
protected checkScreenName: string | null = null;
|
||||
protected checkMuxName: string | null = null;
|
||||
protected checkTempFile: string | null = null;
|
||||
protected checkPromptFile: string | null = null;
|
||||
protected checkPollTimer: NodeJS.Timeout | null = null;
|
||||
@@ -155,7 +155,7 @@ export abstract class AiCheckerBase<
|
||||
// ========== Abstract Properties ==========
|
||||
|
||||
/** Prefix for mux session names (e.g., 'claudeman-aicheck-') */
|
||||
protected abstract readonly screenNamePrefix: string;
|
||||
protected abstract readonly muxNamePrefix: string;
|
||||
|
||||
/** Marker written to temp file when check is complete */
|
||||
protected abstract readonly doneMarker: string;
|
||||
@@ -255,7 +255,7 @@ export abstract class AiCheckerBase<
|
||||
|
||||
/**
|
||||
* Run an AI check against the provided terminal buffer.
|
||||
* Spawns a fresh Claude CLI in a screen, captures output to temp file.
|
||||
* Spawns a fresh Claude CLI in a tmux session, captures output to temp file.
|
||||
*
|
||||
* @param terminalBuffer - Raw terminal output to analyze
|
||||
* @returns The verdict result
|
||||
@@ -387,16 +387,16 @@ export abstract class AiCheckerBase<
|
||||
// Build the prompt
|
||||
const prompt = this.buildPrompt(trimmed);
|
||||
|
||||
// Generate temp files and screen name
|
||||
// Generate temp files and mux session name
|
||||
const shortId = this.sessionId.slice(0, 8);
|
||||
const timestamp = Date.now();
|
||||
this.checkTempFile = join(tmpdir(), `${this.tempFilePrefix}-${shortId}-${timestamp}.txt`);
|
||||
this.checkPromptFile = join(tmpdir(), `${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt`);
|
||||
this.checkScreenName = `${this.screenNamePrefix}${shortId}`;
|
||||
this.checkMuxName = `${this.muxNamePrefix}${shortId}`;
|
||||
|
||||
// Security: Validate screen name before use in shell commands
|
||||
if (!isValidScreenName(this.checkScreenName)) {
|
||||
throw new Error(`Invalid screen name generated: ${this.checkScreenName.substring(0, 50)}`);
|
||||
// Security: Validate mux name before use in shell commands
|
||||
if (!isValidMuxName(this.checkMuxName)) {
|
||||
throw new Error(`Invalid mux name generated: ${this.checkMuxName.substring(0, 50)}`);
|
||||
}
|
||||
|
||||
// Ensure output temp file exists (empty) so we can poll it
|
||||
@@ -413,26 +413,25 @@ export abstract class AiCheckerBase<
|
||||
const claudeCmd = `cat "${this.checkPromptFile}" | claude -p ${modelArg} --output-format text`;
|
||||
const fullCmd = `export PATH="${augmentedPath}"; ${claudeCmd} > "${this.checkTempFile}" 2>&1; echo "${this.doneMarker}" >> "${this.checkTempFile}"; rm -f "${this.checkPromptFile}"`;
|
||||
|
||||
// Spawn screen
|
||||
// Spawn tmux session
|
||||
try {
|
||||
// Kill any leftover screen with this name first (screen name already validated above)
|
||||
// Kill any leftover session with this name first (mux name already validated above)
|
||||
try {
|
||||
execSync(`screen -X -S "${this.checkScreenName}" quit 2>/dev/null`, { timeout: 3000 });
|
||||
execSync(`tmux kill-session -t "${this.checkMuxName}" 2>/dev/null`, { timeout: 3000 });
|
||||
} catch {
|
||||
// No existing screen, that's fine
|
||||
// No existing session, that's fine
|
||||
}
|
||||
|
||||
const screenProcess = childSpawn('screen', [
|
||||
'-dmS', this.checkScreenName,
|
||||
'-c', '/dev/null',
|
||||
const muxProcess = childSpawn('tmux', [
|
||||
'new-session', '-d', '-s', this.checkMuxName,
|
||||
'bash', '-c', fullCmd
|
||||
], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
screenProcess.unref();
|
||||
muxProcess.unref();
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to spawn ${this.checkDescription} screen: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// Poll the temp file for completion
|
||||
@@ -505,23 +504,15 @@ export abstract class AiCheckerBase<
|
||||
this.checkTimeoutTimer = null;
|
||||
}
|
||||
|
||||
// Kill the screen with fallback for stubborn processes
|
||||
if (this.checkScreenName) {
|
||||
const screenName = this.checkScreenName;
|
||||
// Screen name was validated before use, but still quote for defense-in-depth
|
||||
// Kill the tmux session
|
||||
if (this.checkMuxName) {
|
||||
const muxName = this.checkMuxName;
|
||||
try {
|
||||
execSync(`screen -X -S "${screenName}" quit 2>/dev/null`, { timeout: 2000 });
|
||||
execSync(`tmux kill-session -t "${muxName}" 2>/dev/null`, { timeout: 2000 });
|
||||
} catch {
|
||||
// First attempt failed - try force kill via pkill as fallback
|
||||
// Escape regex metacharacters in screen name to prevent pattern injection
|
||||
const escapedName = screenName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
try {
|
||||
execSync(`pkill -f "SCREEN.*${escapedName}" 2>/dev/null`, { timeout: 1000 });
|
||||
} catch {
|
||||
// Screen may already be dead or no matching process
|
||||
}
|
||||
// Session may already be dead
|
||||
}
|
||||
this.checkScreenName = null;
|
||||
this.checkMuxName = null;
|
||||
}
|
||||
|
||||
// Delete temp files
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
/**
|
||||
* @fileoverview AI-Powered Idle Checker for Respawn Controller
|
||||
*
|
||||
* Spawns a fresh Claude CLI session in a screen to analyze terminal output
|
||||
* Spawns a fresh Claude CLI session in a tmux session to analyze terminal output
|
||||
* and provide a definitive IDLE/WORKING verdict. This replaces the "Worked for Xm Xs"
|
||||
* pattern as the primary idle detection signal.
|
||||
*
|
||||
* ## How It Works
|
||||
*
|
||||
* 1. Generate temp file path for output capture
|
||||
* 2. Spawn screen: `screen -dmS claudeman-aicheck-<short> bash -c 'claude -p ...'`
|
||||
* 2. Spawn tmux: `tmux new-session -d -s claudeman-aicheck-<short> bash -c 'claude -p ...'`
|
||||
* 3. Poll the temp file every 500ms for `__AICHECK_DONE__` marker
|
||||
* 4. Parse the file content for IDLE/WORKING on the first word
|
||||
* 5. Kill screen and delete temp file
|
||||
* 5. Kill tmux session and delete temp file
|
||||
*
|
||||
* ## Error Handling
|
||||
*
|
||||
* - Screen spawn fails: 1-min cooldown, increment error counter
|
||||
* - Check times out (90s): Kill screen, 1-min cooldown
|
||||
* - Tmux spawn fails: 1-min cooldown, increment error counter
|
||||
* - Check times out (90s): Kill session, 1-min cooldown
|
||||
* - Can't parse IDLE/WORKING: Treat as WORKING, 1-min cooldown
|
||||
* - 3 consecutive errors: Disable AI check, fall back to noOutputTimeoutMs
|
||||
* - Claude CLI not found: Disable permanently
|
||||
@@ -143,7 +143,7 @@ export class AiIdleChecker extends AiCheckerBase<
|
||||
AiCheckResult,
|
||||
AiCheckState
|
||||
> {
|
||||
protected readonly screenNamePrefix = 'claudeman-aicheck-';
|
||||
protected readonly muxNamePrefix = 'claudeman-aicheck-';
|
||||
protected readonly doneMarker = '__AICHECK_DONE__';
|
||||
protected readonly tempFilePrefix = 'claudeman-aicheck';
|
||||
protected readonly logPrefix = '[AiIdleChecker]';
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
/**
|
||||
* @fileoverview AI-Powered Plan Mode Checker for Auto-Accept
|
||||
*
|
||||
* Spawns a fresh Claude CLI session to analyze terminal output and determine
|
||||
* if Claude Code is showing a plan mode approval prompt (numbered selection menu).
|
||||
* Spawns a fresh Claude CLI session in a tmux session to analyze terminal output
|
||||
* and determine if Claude Code is showing a plan mode approval prompt.
|
||||
* Used as a confirmation gate before auto-accepting prompts.
|
||||
*
|
||||
* ## How It Works
|
||||
*
|
||||
* 1. Generate temp file path for output capture
|
||||
* 2. Spawn screen: `screen -dmS claudeman-plancheck-<short> bash -c 'claude -p ...'`
|
||||
* 2. Spawn tmux: `tmux new-session -d -s claudeman-plancheck-<short> bash -c 'claude -p ...'`
|
||||
* 3. Poll the temp file every 500ms for `__PLANCHECK_DONE__` marker
|
||||
* 4. Parse the file content for PLAN_MODE/NOT_PLAN_MODE on the first line
|
||||
* 5. Kill screen and delete temp file
|
||||
* 5. Kill tmux session and delete temp file
|
||||
*
|
||||
* ## Error Handling
|
||||
*
|
||||
* - Screen spawn fails: 30s cooldown, increment error counter
|
||||
* - Check times out (60s): Kill screen, 30s cooldown
|
||||
* - Tmux spawn fails: 30s cooldown, increment error counter
|
||||
* - Check times out (60s): Kill session, 30s cooldown
|
||||
* - Can't parse verdict: Treat as NOT_PLAN_MODE, 30s cooldown
|
||||
* - 3 consecutive errors: Disable AI plan check
|
||||
*
|
||||
@@ -105,7 +105,7 @@ export class AiPlanChecker extends AiCheckerBase<
|
||||
AiPlanCheckResult,
|
||||
AiPlanCheckState
|
||||
> {
|
||||
protected readonly screenNamePrefix = 'claudeman-plancheck-';
|
||||
protected readonly muxNamePrefix = 'claudeman-plancheck-';
|
||||
protected readonly doneMarker = '__PLANCHECK_DONE__';
|
||||
protected readonly tempFilePrefix = 'claudeman-plancheck';
|
||||
protected readonly logPrefix = '[AiPlanChecker]';
|
||||
|
||||
+8
-45
@@ -1,59 +1,22 @@
|
||||
/**
|
||||
* @fileoverview Factory for creating the appropriate terminal multiplexer.
|
||||
*
|
||||
* Auto-detects tmux vs GNU Screen at startup. Prefers tmux.
|
||||
* Set `CLAUDEMAN_MUX=screen` to force GNU Screen backend.
|
||||
* @fileoverview Factory for creating the terminal multiplexer (tmux).
|
||||
*
|
||||
* @module mux-factory
|
||||
*/
|
||||
|
||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import { ScreenManager } from './screen-manager.js';
|
||||
import { TmuxManager } from './tmux-manager.js';
|
||||
|
||||
/**
|
||||
* Create a TerminalMultiplexer instance based on availability and preference.
|
||||
* Create a TerminalMultiplexer instance.
|
||||
*
|
||||
* Detection order:
|
||||
* 1. If `forced` parameter is set, use that backend
|
||||
* 2. If `CLAUDEMAN_MUX` env var is set, use that backend
|
||||
* 3. If tmux is available, use tmux (preferred)
|
||||
* 4. If screen is available, use screen (deprecated fallback)
|
||||
* 5. Throw error with install instructions
|
||||
* Requires tmux to be installed. Throws with install instructions if not found.
|
||||
*/
|
||||
export function createMultiplexer(forced?: 'tmux' | 'screen'): TerminalMultiplexer {
|
||||
const preference = forced || process.env.CLAUDEMAN_MUX;
|
||||
|
||||
if (preference === 'screen') {
|
||||
if (!ScreenManager.isScreenAvailable()) {
|
||||
throw new Error('GNU Screen requested via CLAUDEMAN_MUX=screen but not found. Install: sudo apt install screen');
|
||||
}
|
||||
console.log('[MuxFactory] Using GNU Screen backend (forced via CLAUDEMAN_MUX)');
|
||||
return new ScreenManager();
|
||||
export function createMultiplexer(): TerminalMultiplexer {
|
||||
if (!TmuxManager.isTmuxAvailable()) {
|
||||
throw new Error('tmux not found. Install: sudo apt install tmux');
|
||||
}
|
||||
|
||||
if (preference === 'tmux') {
|
||||
if (!TmuxManager.isTmuxAvailable()) {
|
||||
throw new Error('tmux requested via CLAUDEMAN_MUX=tmux but not found. Install: sudo apt install tmux');
|
||||
}
|
||||
console.log('[MuxFactory] Using tmux backend (forced via CLAUDEMAN_MUX)');
|
||||
return new TmuxManager();
|
||||
}
|
||||
|
||||
// Auto-detect: prefer tmux
|
||||
if (TmuxManager.isTmuxAvailable()) {
|
||||
console.log('[MuxFactory] Using tmux backend (auto-detected)');
|
||||
return new TmuxManager();
|
||||
}
|
||||
|
||||
if (ScreenManager.isScreenAvailable()) {
|
||||
console.warn('[MuxFactory] Using GNU Screen backend (tmux not found). Consider installing tmux for better performance: sudo apt install tmux');
|
||||
return new ScreenManager();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'No terminal multiplexer found. Install one of:\n' +
|
||||
' - tmux (recommended): sudo apt install tmux\n' +
|
||||
' - GNU Screen (legacy): sudo apt install screen'
|
||||
);
|
||||
console.log('[MuxFactory] Using tmux backend');
|
||||
return new TmuxManager();
|
||||
}
|
||||
|
||||
+7
-18
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Terminal multiplexer abstraction layer.
|
||||
* @fileoverview Terminal multiplexer abstraction layer (tmux).
|
||||
*
|
||||
* Defines the TerminalMultiplexer interface that both ScreenManager (GNU Screen)
|
||||
* and TmuxManager (tmux) implement. This allows the rest of the codebase to work
|
||||
* with either backend transparently.
|
||||
*
|
||||
* The MuxSession type is the backend-agnostic equivalent of ScreenSession.
|
||||
* Defines the TerminalMultiplexer interface that TmuxManager implements.
|
||||
*
|
||||
* @module mux-interface
|
||||
*/
|
||||
@@ -14,8 +10,7 @@ import type { EventEmitter } from 'node:events';
|
||||
import type { ProcessStats, PersistedRespawnConfig, NiceConfig } from './types.js';
|
||||
|
||||
/**
|
||||
* Backend-agnostic multiplexer session.
|
||||
* Equivalent to ScreenSession but uses `muxName` instead of `screenName`.
|
||||
* Multiplexer session metadata.
|
||||
*/
|
||||
export interface MuxSession {
|
||||
/** Claudeman session ID */
|
||||
@@ -51,8 +46,7 @@ export interface MuxSessionWithStats extends MuxSession {
|
||||
/**
|
||||
* Terminal multiplexer interface.
|
||||
*
|
||||
* Both ScreenManager and TmuxManager implement this interface,
|
||||
* allowing the rest of the codebase to work with either backend.
|
||||
* Implemented by TmuxManager.
|
||||
*
|
||||
* Events emitted:
|
||||
* - `sessionCreated` (session: MuxSession) - New session created
|
||||
@@ -62,7 +56,7 @@ export interface MuxSessionWithStats extends MuxSession {
|
||||
*/
|
||||
export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
readonly backend: 'tmux' | 'screen';
|
||||
readonly backend: 'tmux';
|
||||
|
||||
// ========== Lifecycle ==========
|
||||
|
||||
@@ -104,9 +98,7 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
// ========== Input ==========
|
||||
|
||||
/**
|
||||
* Send input to a session.
|
||||
* tmux: `send-keys -l 'text' Enter` (single command, no delay)
|
||||
* screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries)
|
||||
* Send input to a session via tmux send-keys.
|
||||
*/
|
||||
sendInput(sessionId: string, input: string): Promise<boolean>;
|
||||
|
||||
@@ -149,15 +141,12 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
// ========== PTY Attachment ==========
|
||||
|
||||
/**
|
||||
* Get the command to spawn for attaching to a session.
|
||||
* Returns 'tmux' or 'screen'.
|
||||
* Get the command to spawn for attaching to a session ('tmux').
|
||||
*/
|
||||
getAttachCommand(): string;
|
||||
|
||||
/**
|
||||
* Get the arguments for attaching to a session by mux name.
|
||||
* tmux: ['attach-session', '-t', muxName]
|
||||
* screen: ['-x', muxName]
|
||||
*/
|
||||
getAttachArgs(muxName: string): string[];
|
||||
|
||||
|
||||
@@ -390,8 +390,8 @@ export class PlanOrchestrator {
|
||||
|
||||
const session = new Session({
|
||||
workingDir: this.workingDir,
|
||||
screenManager: this.mux,
|
||||
useScreen: false,
|
||||
mux: this.mux,
|
||||
useMux: false,
|
||||
mode: 'claude',
|
||||
});
|
||||
|
||||
@@ -472,8 +472,8 @@ export class PlanOrchestrator {
|
||||
|
||||
const session = new Session({
|
||||
workingDir: this.workingDir,
|
||||
screenManager: this.mux,
|
||||
useScreen: false,
|
||||
mux: this.mux,
|
||||
useMux: false,
|
||||
mode: 'claude',
|
||||
});
|
||||
|
||||
|
||||
@@ -1697,7 +1697,7 @@ export class RespawnController extends EventEmitter {
|
||||
if (this._state === 'stopped') return;
|
||||
const prompt = this.config.kickstartPrompt!;
|
||||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||
await this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||
this.emit('stepSent', 'kickstart', prompt);
|
||||
this.setState('waiting_kickstart');
|
||||
this.promptDetected = false;
|
||||
@@ -2466,7 +2466,7 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
this.logAction('command', 'Auto-accept: ↵ Enter (plan approved)');
|
||||
this.emit('stepSent', 'auto-accept', '↵');
|
||||
void this.session.writeViaScreen('\r');
|
||||
void this.session.writeViaMux('\r');
|
||||
this.emit('autoAcceptSent');
|
||||
// Reset so we don't keep spamming Enter if Claude doesn't respond
|
||||
this.hasReceivedOutput = false;
|
||||
@@ -2886,7 +2886,7 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
||||
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
|
||||
await this.session.writeViaScreen(input);
|
||||
await this.session.writeViaMux(input);
|
||||
this.emit('stepSent', 'update', updatePrompt);
|
||||
this.setState('waiting_update');
|
||||
this.promptDetected = false;
|
||||
@@ -2914,7 +2914,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.stepTimer = null;
|
||||
if (this._state === 'stopped') return;
|
||||
this.logAction('command', 'Sending: /clear');
|
||||
await this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
this.emit('stepSent', 'clear', '/clear');
|
||||
this.setState('waiting_clear');
|
||||
this.promptDetected = false;
|
||||
@@ -2958,7 +2958,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.stepTimer = null;
|
||||
if (this._state === 'stopped') return;
|
||||
this.logAction('command', 'Sending: /init');
|
||||
await this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
this.emit('stepSent', 'init', '/init');
|
||||
this.setState('waiting_init');
|
||||
this.promptDetected = false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+39
-50
@@ -8,8 +8,8 @@
|
||||
* 2. **Interactive mode** (`startInteractive`): Start an interactive Claude session
|
||||
* 3. **Shell mode**: Run a plain bash shell for debugging/testing
|
||||
*
|
||||
* The session can optionally run inside a tmux session (or GNU Screen as fallback)
|
||||
* for persistence across disconnects. It tracks tokens, costs, background tasks, and supports
|
||||
* The session can optionally run inside a tmux session for persistence across disconnects.
|
||||
* It tracks tokens, costs, background tasks, and supports
|
||||
* auto-clear/auto-compact functionality when token limits are approached.
|
||||
*
|
||||
* @module session
|
||||
@@ -50,8 +50,8 @@ const LINE_BUFFER_FLUSH_INTERVAL = 100;
|
||||
// Timing Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Delay after screen creation before sending commands (300ms) */
|
||||
const SCREEN_STARTUP_DELAY_MS = 300;
|
||||
/** Delay after mux session creation before sending commands (300ms) */
|
||||
const MUX_STARTUP_DELAY_MS = 300;
|
||||
|
||||
/** Delay before declaring session idle after last output (2 seconds) */
|
||||
const IDLE_DETECTION_DELAY_MS = 2000;
|
||||
@@ -161,7 +161,7 @@ export interface SessionEvents {
|
||||
completion: (result: string, cost: number) => void;
|
||||
/** Raw terminal data (includes ANSI codes) */
|
||||
terminal: (data: string) => void;
|
||||
/** Signal to clear terminal display (after screen attach) */
|
||||
/** Signal to clear terminal display (after mux attach) */
|
||||
clearTerminal: () => void;
|
||||
/** New background task started */
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
@@ -210,8 +210,8 @@ export type SessionMode = 'claude' | 'shell';
|
||||
* // Create and start an interactive Claude session
|
||||
* const session = new Session({
|
||||
* workingDir: '/path/to/project',
|
||||
* screenManager: screenManager,
|
||||
* useScreen: true
|
||||
* mux: muxManager,
|
||||
* useMux: true
|
||||
* });
|
||||
* await session.startInteractive();
|
||||
*
|
||||
@@ -300,7 +300,7 @@ export class Session extends EventEmitter {
|
||||
private _promptCheckTimeout: NodeJS.Timeout | null = null;
|
||||
private _shellIdleTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Multiplexer session support (tmux or GNU Screen)
|
||||
// Multiplexer session support (tmux)
|
||||
private _mux: TerminalMultiplexer | null = null;
|
||||
private _muxSession: MuxSession | null = null;
|
||||
private _useMux: boolean = false;
|
||||
@@ -354,18 +354,12 @@ export class Session extends EventEmitter {
|
||||
workingDir: string;
|
||||
mode?: SessionMode;
|
||||
name?: string;
|
||||
/** Terminal multiplexer instance (tmux or screen) */
|
||||
/** Terminal multiplexer instance (tmux) */
|
||||
mux?: TerminalMultiplexer;
|
||||
/** Whether to use multiplexer wrapping */
|
||||
useMux?: boolean;
|
||||
/** Existing mux session for restored sessions */
|
||||
muxSession?: MuxSession;
|
||||
/** @deprecated Use `mux` instead */
|
||||
screenManager?: TerminalMultiplexer;
|
||||
/** @deprecated Use `useMux` instead */
|
||||
useScreen?: boolean;
|
||||
/** @deprecated Use `muxSession` instead */
|
||||
screenSession?: MuxSession;
|
||||
niceConfig?: NiceConfig; // Nice prioritying configuration
|
||||
}) {
|
||||
super();
|
||||
@@ -388,10 +382,9 @@ export class Session extends EventEmitter {
|
||||
// This ensures subagent matching works even for recovered sessions (where
|
||||
// startInteractive() hasn't been called yet).
|
||||
this._claudeSessionId = this.id;
|
||||
// Support both new (mux) and deprecated (screenManager) parameter names
|
||||
this._mux = config.mux || config.screenManager || null;
|
||||
this._useMux = config.useMux ?? config.useScreen ?? (this._mux !== null && this._mux.isAvailable());
|
||||
this._muxSession = config.muxSession || config.screenSession || null;
|
||||
this._mux = config.mux || null;
|
||||
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
|
||||
this._muxSession = config.muxSession || null;
|
||||
|
||||
// Apply Nice priority configuration if provided
|
||||
if (config.niceConfig) {
|
||||
@@ -815,14 +808,14 @@ export class Session extends EventEmitter {
|
||||
* Starts an interactive Claude CLI session with full terminal support.
|
||||
*
|
||||
* This spawns Claude CLI with `--dangerously-skip-permissions` flag in
|
||||
* interactive mode. If screen wrapping is enabled, the session runs inside
|
||||
* a tmux session (or GNU Screen as fallback) for persistence across disconnects.
|
||||
* interactive mode. If mux wrapping is enabled, the session runs inside
|
||||
* a tmux session for persistence across disconnects.
|
||||
*
|
||||
* @throws {Error} If a process is already running in this session
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const session = new Session({ workingDir: '/project', useScreen: true });
|
||||
* const session = new Session({ workingDir: '/project', useMux: true });
|
||||
* await session.startInteractive();
|
||||
* session.on('terminal', (data) => process.stdout.write(data));
|
||||
* session.write('help me with this code\r');
|
||||
@@ -856,7 +849,7 @@ export class Session extends EventEmitter {
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
|
||||
// Wait a moment for mux to fully start
|
||||
await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS));
|
||||
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
@@ -876,13 +869,13 @@ export class Session extends EventEmitter {
|
||||
// The mux manager passes --session-id ${sessionId} to Claude
|
||||
this._claudeSessionId = this.id;
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn PTY for screen attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to screen: ${spawnErr}`);
|
||||
console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
throw spawnErr;
|
||||
}
|
||||
|
||||
// For NEW screens: wait for prompt to appear then clean buffer
|
||||
// For RESTORED screens: don't do anything - client will fetch buffer on tab switch
|
||||
// For NEW mux sessions: wait for prompt to appear then clean buffer
|
||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||
if (!isRestoredSession) {
|
||||
this._promptCheckInterval = setInterval(() => {
|
||||
// Wait for the prompt character (❯) which means Claude is fully initialized
|
||||
@@ -896,7 +889,7 @@ export class Session extends EventEmitter {
|
||||
clearTimeout(this._promptCheckTimeout);
|
||||
this._promptCheckTimeout = null;
|
||||
}
|
||||
// Clean the buffer - remove screen init junk before actual content
|
||||
// Clean the buffer - remove mux init junk before actual content
|
||||
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
||||
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
||||
this._terminalBuffer.set(
|
||||
@@ -942,7 +935,7 @@ export class Session extends EventEmitter {
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
// Inform Claude it's running within Claudeman (helps prevent self-termination)
|
||||
CLAUDEMAN_SCREEN: '1',
|
||||
CLAUDEMAN_MUX: '1',
|
||||
CLAUDEMAN_SESSION_ID: this.id,
|
||||
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
@@ -1136,7 +1129,7 @@ export class Session extends EventEmitter {
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
|
||||
// Wait a moment for mux to fully start
|
||||
await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS));
|
||||
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
@@ -1185,7 +1178,7 @@ export class Session extends EventEmitter {
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
CLAUDEMAN_SCREEN: '1',
|
||||
CLAUDEMAN_MUX: '1',
|
||||
CLAUDEMAN_SESSION_ID: this.id,
|
||||
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
@@ -1317,7 +1310,7 @@ export class Session extends EventEmitter {
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
// Inform Claude it's running within Claudeman
|
||||
CLAUDEMAN_SCREEN: '1',
|
||||
CLAUDEMAN_MUX: '1',
|
||||
CLAUDEMAN_SESSION_ID: this.id,
|
||||
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
@@ -1770,7 +1763,7 @@ export class Session extends EventEmitter {
|
||||
const compactCmd = this._autoCompactPrompt
|
||||
? `/compact ${this._autoCompactPrompt}\r`
|
||||
: '/compact\r';
|
||||
await this.writeViaScreen(compactCmd);
|
||||
await this.writeViaMux(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
threshold: this._autoCompactThreshold,
|
||||
@@ -1821,7 +1814,7 @@ export class Session extends EventEmitter {
|
||||
if (this._isStopped) return;
|
||||
|
||||
// Send /clear command
|
||||
await this.writeViaScreen('/clear\r');
|
||||
await this.writeViaMux('/clear\r');
|
||||
// Reset token counts
|
||||
this._totalInputTokens = 0;
|
||||
this._totalOutputTokens = 0;
|
||||
@@ -1876,22 +1869,18 @@ export class Session extends EventEmitter {
|
||||
*
|
||||
* More reliable than direct PTY write for programmatic input, especially
|
||||
* with Claude CLI which uses Ink (React for terminals).
|
||||
* - tmux: `send-keys -l 'text' Enter` (single command, no delay)
|
||||
* - screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries)
|
||||
*
|
||||
* Method name kept as `writeViaScreen` for backward compatibility with
|
||||
* respawn-controller and other callers.
|
||||
* Uses tmux `send-keys -l` to inject text + Enter.
|
||||
*
|
||||
* @param data - Input data with optional `\r` for Enter
|
||||
* @returns true if input was sent, false if no mux session or PTY
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* session.writeViaScreen('/clear\r'); // Send /clear command
|
||||
* session.writeViaScreen('/init\r'); // Send /init command
|
||||
* session.writeViaMux('/clear\r'); // Send /clear command
|
||||
* session.writeViaMux('/init\r'); // Send /init command
|
||||
* ```
|
||||
*/
|
||||
async writeViaScreen(data: string): Promise<boolean> {
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
if (this._mux && this._muxSession) {
|
||||
return this._mux.sendInput(this.id, data);
|
||||
}
|
||||
@@ -1974,21 +1963,21 @@ export class Session extends EventEmitter {
|
||||
/**
|
||||
* Stops the session and cleans up resources.
|
||||
*
|
||||
* This kills the PTY process and optionally the associated tmux/screen
|
||||
* session. All buffers are cleared and the session is marked as stopped.
|
||||
* This kills the PTY process and optionally the associated tmux session.
|
||||
* All buffers are cleared and the session is marked as stopped.
|
||||
*
|
||||
* @param killScreen - Whether to also kill the screen session (default: true)
|
||||
* @param killMux - Whether to also kill the mux session (default: true)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Stop and kill everything
|
||||
* await session.stop();
|
||||
*
|
||||
* // Stop but keep screen running for later reattachment
|
||||
* // Stop but keep mux session running for later reattachment
|
||||
* await session.stop(false);
|
||||
* ```
|
||||
*/
|
||||
async stop(killScreen: boolean = true): Promise<void> {
|
||||
async stop(killMux: boolean = true): Promise<void> {
|
||||
// Set stopped flag first to prevent new timers from being created
|
||||
this._isStopped = true;
|
||||
|
||||
@@ -2034,7 +2023,7 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Immediately cleanup Promise callbacks to prevent orphaned references
|
||||
// during the rest of stop() processing (e.g., if screen kill times out)
|
||||
// during the rest of stop() processing (e.g., if mux kill times out)
|
||||
if (this.rejectPromise && !this._promptResolved) {
|
||||
this._promptResolved = true;
|
||||
this.rejectPromise(new Error('Session stopped'));
|
||||
@@ -2087,7 +2076,7 @@ export class Session extends EventEmitter {
|
||||
this._childAgentIds = [];
|
||||
|
||||
// Kill the associated mux session if requested
|
||||
if (killScreen && this._mux) {
|
||||
if (killMux && this._mux) {
|
||||
// Try to kill mux session even if _muxSession is not set (e.g., restored sessions)
|
||||
try {
|
||||
const killed = await this._mux.killSession(this.id);
|
||||
@@ -2098,7 +2087,7 @@ export class Session extends EventEmitter {
|
||||
console.error('[Session] Failed to kill mux session:', err);
|
||||
}
|
||||
this._muxSession = null;
|
||||
} else if (this._muxSession && !killScreen) {
|
||||
} else if (this._muxSession && !killMux) {
|
||||
console.log('[Session] Keeping mux session alive:', this._muxSession.muxName);
|
||||
this._muxSession = null; // Detach but don't kill
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ Then update the Project Overview section below.
|
||||
|
||||
## Claudeman Environment
|
||||
|
||||
This session is managed by **Claudeman** and runs within a tmux session (or GNU Screen as fallback).
|
||||
This session is managed by **Claudeman** and runs within a tmux session.
|
||||
|
||||
**Important**: Check for `CLAUDEMAN_SCREEN=1` environment variable to confirm.
|
||||
- Do NOT attempt to kill your own tmux/screen session
|
||||
**Important**: Check for `CLAUDEMAN_MUX=1` environment variable to confirm.
|
||||
- Do NOT attempt to kill your own tmux session
|
||||
- The session persists across disconnects - your work is safe
|
||||
- Token usage, costs, and background tasks are tracked externally
|
||||
|
||||
|
||||
+13
-41
@@ -12,11 +12,11 @@
|
||||
*
|
||||
* tmux sessions are named `claudeman-{sessionId}` and stored in ~/.claudeman/mux-sessions.json.
|
||||
*
|
||||
* Key advantages over GNU Screen:
|
||||
* - `send-keys 'text' Enter` eliminates the text+CR split hack (no 100ms delay, no retries)
|
||||
* - `list-sessions -F` provides structured queries (no regex parsing)
|
||||
* Key features:
|
||||
* - `send-keys 'text' Enter` sends literal text in a single command
|
||||
* - `list-sessions -F` provides structured queries
|
||||
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
|
||||
* - Single server architecture vs per-session processes
|
||||
* - Single server architecture
|
||||
*
|
||||
* @module tmux-manager
|
||||
*/
|
||||
@@ -44,7 +44,7 @@ import { findClaudeDir } from './utils/claude-cli-resolver.js';
|
||||
/** Timeout for exec commands (5 seconds) */
|
||||
const EXEC_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Delay after tmux session creation (300ms — faster than screen's 500ms) */
|
||||
/** Delay after tmux session creation (300ms) */
|
||||
const TMUX_CREATION_WAIT_MS = 300;
|
||||
|
||||
/** Delay after tmux kill command (200ms) */
|
||||
@@ -74,9 +74,6 @@ const IS_TEST_MODE = !!process.env.VITEST;
|
||||
/** Path to persisted mux session metadata */
|
||||
const MUX_SESSIONS_FILE = join(homedir(), '.claudeman', 'mux-sessions.json');
|
||||
|
||||
/** Path to legacy screen sessions (for migration) */
|
||||
const LEGACY_SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
||||
|
||||
/** Regex to validate tmux session names (only allow safe characters) */
|
||||
const SAFE_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
|
||||
|
||||
@@ -115,8 +112,7 @@ function isValidPath(path: string): boolean {
|
||||
/**
|
||||
* Manages tmux sessions that wrap Claude CLI or shell processes.
|
||||
*
|
||||
* Implements the TerminalMultiplexer interface for use as a drop-in
|
||||
* replacement for ScreenManager.
|
||||
* Implements the TerminalMultiplexer interface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
@@ -163,30 +159,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.sessions.set(session.sessionId, session);
|
||||
}
|
||||
}
|
||||
} else if (existsSync(LEGACY_SCREENS_FILE)) {
|
||||
// Migration: load from legacy screens.json
|
||||
console.log('[TmuxManager] Migrating sessions from legacy screens.json');
|
||||
const content = readFileSync(LEGACY_SCREENS_FILE, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (Array.isArray(data)) {
|
||||
for (const screen of data) {
|
||||
const session: MuxSession = {
|
||||
sessionId: screen.sessionId,
|
||||
muxName: screen.screenName,
|
||||
pid: screen.pid,
|
||||
createdAt: screen.createdAt,
|
||||
workingDir: screen.workingDir,
|
||||
mode: screen.mode,
|
||||
attached: screen.attached,
|
||||
name: screen.name,
|
||||
respawnConfig: screen.respawnConfig,
|
||||
ralphEnabled: screen.ralphEnabled,
|
||||
};
|
||||
this.sessions.set(session.sessionId, session);
|
||||
}
|
||||
this.saveSessions();
|
||||
console.log(`[TmuxManager] Migrated ${data.length} sessions from screens.json`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to load sessions:', err);
|
||||
@@ -262,9 +234,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const envExports = [
|
||||
'unset CLAUDECODE',
|
||||
'unset COLORTERM',
|
||||
'export CLAUDEMAN_SCREEN=1',
|
||||
'export CLAUDEMAN_MUX=1',
|
||||
`export CLAUDEMAN_SESSION_ID=${sessionId}`,
|
||||
`export CLAUDEMAN_SCREEN_NAME=${muxName}`,
|
||||
`export CLAUDEMAN_MUX_NAME=${muxName}`,
|
||||
`export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`,
|
||||
].join(' && ');
|
||||
|
||||
@@ -440,7 +412,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
/**
|
||||
* Kill a tmux session and all its child processes.
|
||||
* Uses the same 4-strategy approach as ScreenManager.
|
||||
* Uses a 4-strategy approach (children → process group → tmux kill → SIGKILL).
|
||||
* In test mode: removes from memory only (no real kill).
|
||||
*/
|
||||
async killSession(sessionId: string): Promise<boolean> {
|
||||
@@ -457,7 +429,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
// SAFETY: Never kill the tmux session we're running inside of
|
||||
const currentMuxName = process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
const currentMuxName = process.env.CLAUDEMAN_MUX_NAME;
|
||||
if (currentMuxName && session.muxName === currentMuxName) {
|
||||
console.error(`[TmuxManager] BLOCKED: Refusing to kill own tmux session: ${session.muxName}`);
|
||||
return false;
|
||||
@@ -899,10 +871,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
/**
|
||||
* Send input directly to a tmux session using `send-keys`.
|
||||
*
|
||||
* This is significantly simpler than Screen's approach:
|
||||
* Uses tmux send-keys for reliable input delivery:
|
||||
* - `-l` flag sends literal text (no key interpretation)
|
||||
* - `Enter` key is sent as a separate argument (not a shell escape)
|
||||
* - Single command, no delay, no retry loop needed
|
||||
* - `Enter` key is sent as a separate argument
|
||||
* - Single command, no delay needed
|
||||
*/
|
||||
async sendInput(sessionId: string, input: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
+3
-39
@@ -7,7 +7,7 @@
|
||||
* - Task queue operations
|
||||
* - Ralph Loop configuration
|
||||
* - API requests/responses
|
||||
* - Screen session handling
|
||||
* - Mux session handling
|
||||
* - Inner loop tracking (Ralph Wiggum detection)
|
||||
*/
|
||||
|
||||
@@ -852,15 +852,10 @@ export interface CaseInfo {
|
||||
hasClaudeMd?: boolean;
|
||||
}
|
||||
|
||||
// ========== Screen Session Types ==========
|
||||
// ========== Mux Session Types ==========
|
||||
|
||||
/**
|
||||
* Terminal multiplexer session wrapper.
|
||||
*
|
||||
* Claudeman uses tmux (preferred) or GNU Screen (fallback) for session persistence.
|
||||
*/
|
||||
/**
|
||||
* Persisted respawn configuration for screen sessions.
|
||||
* Persisted respawn configuration for mux sessions.
|
||||
* Subset of RespawnConfig that gets saved to disk.
|
||||
*/
|
||||
export interface PersistedRespawnConfig {
|
||||
@@ -910,29 +905,6 @@ export interface PersistedRespawnConfig {
|
||||
durationMinutes?: number;
|
||||
}
|
||||
|
||||
export interface ScreenSession {
|
||||
/** Claudeman session ID */
|
||||
sessionId: string;
|
||||
/** Mux session name (claudeman-<id>) */
|
||||
screenName: string;
|
||||
/** Screen process PID */
|
||||
pid: number;
|
||||
/** Timestamp when created */
|
||||
createdAt: number;
|
||||
/** Working directory */
|
||||
workingDir: string;
|
||||
/** Session mode: claude or shell */
|
||||
mode: 'claude' | 'shell';
|
||||
/** Whether webserver is attached to this screen */
|
||||
attached: boolean;
|
||||
/** Session display name (tab name) */
|
||||
name?: string;
|
||||
/** Persisted respawn controller configuration (restored on server restart) */
|
||||
respawnConfig?: PersistedRespawnConfig;
|
||||
/** Whether Ralph / Todo tracking is enabled */
|
||||
ralphEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process resource statistics
|
||||
*/
|
||||
@@ -947,14 +919,6 @@ export interface ProcessStats {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen session with resource statistics
|
||||
*/
|
||||
export interface ScreenSessionWithStats extends ScreenSession {
|
||||
/** Optional resource statistics */
|
||||
stats?: ProcessStats;
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @fileoverview Shared Claude CLI binary resolution.
|
||||
*
|
||||
* Finds the `claude` binary across common installation paths and provides
|
||||
* an augmented PATH string. Used by session.ts, tmux-manager.ts, and
|
||||
* screen-manager.ts to locate the Claude CLI.
|
||||
* an augmented PATH string. Used by session.ts and tmux-manager.ts
|
||||
* to locate the Claude CLI.
|
||||
*
|
||||
* @module utils/claude-cli-resolver
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Shared utility for wrapping commands with `nice` priority.
|
||||
*
|
||||
* Extracted from screen-manager.ts so both tmux-manager and screen-manager
|
||||
* can use it without cross-dependency on the deprecated screen module.
|
||||
* Extracted as a shared utility for tmux-manager and other consumers.
|
||||
*
|
||||
* @module utils/nice-wrapper
|
||||
*/
|
||||
|
||||
+63
-63
@@ -1350,7 +1350,7 @@ class ClaudemanApp {
|
||||
this.terminalBuffers = new Map(); // Store terminal content per session
|
||||
this.editingSessionId = null; // Session being edited in options modal
|
||||
this.pendingCloseSessionId = null; // Session pending close confirmation
|
||||
this.screenSessions = []; // Screen sessions for process monitor
|
||||
this.muxSessions = []; // Screen sessions for process monitor
|
||||
|
||||
// Ralph loop/todo state per session
|
||||
this.ralphStates = new Map(); // Map<sessionId, { loop, todos }>
|
||||
@@ -1428,7 +1428,7 @@ class ClaudemanApp {
|
||||
this.renderSessionTabsTimeout = null;
|
||||
this.renderRalphStatePanelTimeout = null;
|
||||
this.renderTaskPanelTimeout = null;
|
||||
this.renderScreenSessionsTimeout = null;
|
||||
this.renderMuxSessionsTimeout = null;
|
||||
|
||||
// System stats polling
|
||||
this.systemStatsInterval = null;
|
||||
@@ -2766,29 +2766,29 @@ class ClaudemanApp {
|
||||
});
|
||||
|
||||
// Screen events
|
||||
addListener('screen:created', (e) => {
|
||||
addListener('mux:created', (e) => {
|
||||
const screen = JSON.parse(e.data);
|
||||
this.screenSessions.push(screen);
|
||||
this.renderScreenSessions();
|
||||
this.muxSessions.push(screen);
|
||||
this.renderMuxSessions();
|
||||
});
|
||||
|
||||
addListener('screen:killed', (e) => {
|
||||
addListener('mux:killed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
|
||||
this.renderScreenSessions();
|
||||
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId);
|
||||
this.renderMuxSessions();
|
||||
});
|
||||
|
||||
addListener('screen:died', (e) => {
|
||||
addListener('mux:died', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
|
||||
this.renderScreenSessions();
|
||||
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId);
|
||||
this.renderMuxSessions();
|
||||
this.showToast('Mux session died: ' + data.sessionId.slice(0, 8), 'warning');
|
||||
});
|
||||
|
||||
addListener('screen:statsUpdated', (e) => {
|
||||
this.screenSessions = JSON.parse(e.data);
|
||||
addListener('mux:statsUpdated', (e) => {
|
||||
this.muxSessions = JSON.parse(e.data);
|
||||
if (document.getElementById('monitorPanel').classList.contains('open')) {
|
||||
this.renderScreenSessions();
|
||||
this.renderMuxSessions();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3272,7 +3272,7 @@ class ClaudemanApp {
|
||||
|
||||
// Chain on dispatch only — wait for the previous request to be sent before
|
||||
// dispatching the next one (preserves keystroke ordering), but don't wait
|
||||
// for the server's response. The server handles writeViaScreen as
|
||||
// for the server's response. The server handles writeViaMux as
|
||||
// fire-and-forget anyway, so the HTTP response carries no useful data
|
||||
// beyond success/failure for retry purposes.
|
||||
this._inputSendChain = this._inputSendChain.then(() => {
|
||||
@@ -4318,9 +4318,9 @@ class ClaudemanApp {
|
||||
delete this.respawnActionLogs[sessionId];
|
||||
}
|
||||
|
||||
async closeSession(sessionId, killScreen = true) {
|
||||
async closeSession(sessionId, killMux = true) {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${sessionId}?killMux=${killMux}`, { method: 'DELETE' });
|
||||
this._cleanupSessionData(sessionId);
|
||||
|
||||
if (this.activeSessionId === sessionId) {
|
||||
@@ -4339,7 +4339,7 @@ class ClaudemanApp {
|
||||
|
||||
this.renderSessionTabs();
|
||||
|
||||
if (killScreen) {
|
||||
if (killMux) {
|
||||
this.showToast('Session closed and tmux killed', 'success');
|
||||
} else {
|
||||
this.showToast('Tab hidden, tmux still running', 'info');
|
||||
@@ -4369,12 +4369,12 @@ class ClaudemanApp {
|
||||
document.getElementById('closeConfirmModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async confirmCloseSession(killScreen = true) {
|
||||
async confirmCloseSession(killMux = true) {
|
||||
const sessionId = this.pendingCloseSessionId;
|
||||
this.cancelCloseSession();
|
||||
|
||||
if (sessionId) {
|
||||
await this.closeSession(sessionId, killScreen);
|
||||
await this.closeSession(sessionId, killMux);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7187,7 +7187,7 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
// Step 3: Enable respawn if requested (with Ralph-specific prompts)
|
||||
// NOTE: Prompts must be single-line because screen-manager.ts strips newlines
|
||||
// NOTE: Prompts must be single-line because tmux send-keys handles them as single input
|
||||
if (config.enableRespawn) {
|
||||
const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
|
||||
|
||||
@@ -7269,7 +7269,7 @@ class ClaudemanApp {
|
||||
const initRes = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '/init\r', useScreen: true })
|
||||
body: JSON.stringify({ input: '/init\r', useMux: true })
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
@@ -7319,7 +7319,7 @@ class ClaudemanApp {
|
||||
const inputRes = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: readCommand, useScreen: true })
|
||||
body: JSON.stringify({ input: readCommand, useMux: true })
|
||||
});
|
||||
|
||||
if (!inputRes.ok) {
|
||||
@@ -8363,7 +8363,7 @@ class ClaudemanApp {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, useScreen: true })
|
||||
body: JSON.stringify({ input, useMux: true })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10473,13 +10473,13 @@ class ClaudemanApp {
|
||||
|
||||
if (panel.classList.contains('open')) {
|
||||
// Load screens and start stats collection
|
||||
await this.loadScreens();
|
||||
await fetch('/api/screens/stats/start', { method: 'POST' });
|
||||
await this.loadMuxSessions();
|
||||
await fetch('/api/mux-sessions/stats/start', { method: 'POST' });
|
||||
this.renderTaskPanel();
|
||||
if (toggleBtn) toggleBtn.innerHTML = '▼'; // Down arrow when open
|
||||
} else {
|
||||
// Stop stats collection when panel is closed
|
||||
await fetch('/api/screens/stats/stop', { method: 'POST' });
|
||||
await fetch('/api/mux-sessions/stats/stop', { method: 'POST' });
|
||||
if (toggleBtn) toggleBtn.innerHTML = '▲'; // Up arrow when closed
|
||||
}
|
||||
}
|
||||
@@ -14659,19 +14659,19 @@ class ClaudemanApp {
|
||||
|
||||
// ========== Mux Sessions (in Monitor Panel) ==========
|
||||
|
||||
async loadScreens() {
|
||||
async loadMuxSessions() {
|
||||
try {
|
||||
const res = await fetch('/api/screens');
|
||||
const res = await fetch('/api/mux-sessions');
|
||||
const data = await res.json();
|
||||
this.screenSessions = data.screens || [];
|
||||
this.renderScreenSessions();
|
||||
this.muxSessions = data.sessions || [];
|
||||
this.renderMuxSessions();
|
||||
} catch (err) {
|
||||
console.error('Failed to load screens:', err);
|
||||
console.error('Failed to load mux sessions:', err);
|
||||
}
|
||||
}
|
||||
|
||||
killAllSessions() {
|
||||
const count = this.screenSessions?.length || 0;
|
||||
const count = this.muxSessions?.length || 0;
|
||||
if (count === 0) {
|
||||
alert('No sessions to kill');
|
||||
return;
|
||||
@@ -14697,27 +14697,27 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
async confirmKillAll(killScreens) {
|
||||
async confirmKillAll(killMux) {
|
||||
this.closeKillAllModal();
|
||||
|
||||
try {
|
||||
if (killScreens) {
|
||||
if (killMux) {
|
||||
// Kill everything including tmux sessions
|
||||
const res = await fetch('/api/sessions', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.sessions.clear();
|
||||
this.screenSessions = [];
|
||||
this.muxSessions = [];
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('claudeman-active-session'); } catch {}
|
||||
this.renderSessionTabs();
|
||||
this.renderScreenSessions();
|
||||
this.renderMuxSessions();
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
this.toast('All sessions and tmux killed', 'success');
|
||||
}
|
||||
} else {
|
||||
// Just remove tabs, keep screens running
|
||||
// Just remove tabs, keep mux sessions running
|
||||
this.sessions.clear();
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('claudeman-active-session'); } catch {}
|
||||
@@ -15048,43 +15048,43 @@ class ClaudemanApp {
|
||||
setTimeout(() => modal.classList.remove('from-mobile'), 300);
|
||||
}
|
||||
|
||||
renderScreenSessions() {
|
||||
renderMuxSessions() {
|
||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||||
if (this.renderScreenSessionsTimeout) {
|
||||
clearTimeout(this.renderScreenSessionsTimeout);
|
||||
if (this.renderMuxSessionsTimeout) {
|
||||
clearTimeout(this.renderMuxSessionsTimeout);
|
||||
}
|
||||
this.renderScreenSessionsTimeout = setTimeout(() => {
|
||||
this._renderScreenSessionsImmediate();
|
||||
this.renderMuxSessionsTimeout = setTimeout(() => {
|
||||
this._renderMuxSessionsImmediate();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
_renderScreenSessionsImmediate() {
|
||||
const body = document.getElementById('screenSessionsBody');
|
||||
_renderMuxSessionsImmediate() {
|
||||
const body = document.getElementById('muxSessionsBody');
|
||||
|
||||
if (!this.screenSessions || this.screenSessions.length === 0) {
|
||||
if (!this.muxSessions || this.muxSessions.length === 0) {
|
||||
body.innerHTML = '<div class="monitor-empty">No mux sessions</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const screen of this.screenSessions) {
|
||||
const stats = screen.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 };
|
||||
const modeClass = screen.mode === 'shell' ? 'shell' : '';
|
||||
for (const muxSession of this.muxSessions) {
|
||||
const stats = muxSession.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 };
|
||||
const modeClass = muxSession.mode === 'shell' ? 'shell' : '';
|
||||
|
||||
html += `
|
||||
<div class="process-item">
|
||||
<span class="process-mode ${modeClass}">${screen.mode}</span>
|
||||
<span class="process-mode ${modeClass}">${muxSession.mode}</span>
|
||||
<div class="process-info">
|
||||
<div class="process-name">${this.escapeHtml(screen.name || screen.screenName)}</div>
|
||||
<div class="process-name">${this.escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
||||
<div class="process-meta">
|
||||
<span class="process-stat memory">${stats.memoryMB}MB</span>
|
||||
<span class="process-stat cpu">${stats.cpuPercent}%</span>
|
||||
<span class="process-stat children">${stats.childCount} children</span>
|
||||
<span>PID: ${screen.pid}</span>
|
||||
<span>PID: ${muxSession.pid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killScreen('${this.escapeHtml(screen.sessionId)}')" title="Kill tmux session">Kill</button>
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killMuxSession('${this.escapeHtml(muxSession.sessionId)}')" title="Kill tmux session">Kill</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -15136,32 +15136,32 @@ class ClaudemanApp {
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
async killScreen(sessionId) {
|
||||
async killMuxSession(sessionId) {
|
||||
if (!confirm('Kill this mux session?')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' });
|
||||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== sessionId);
|
||||
this.renderScreenSessions();
|
||||
await fetch(`/api/mux-sessions/${sessionId}`, { method: 'DELETE' });
|
||||
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== sessionId);
|
||||
this.renderMuxSessions();
|
||||
this.showToast('Tmux session killed', 'success');
|
||||
} catch (err) {
|
||||
this.showToast('Failed to kill tmux session', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileScreens() {
|
||||
async reconcileMuxSessions() {
|
||||
try {
|
||||
const res = await fetch('/api/screens/reconcile', { method: 'POST' });
|
||||
const res = await fetch('/api/mux-sessions/reconcile', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.dead && data.dead.length > 0) {
|
||||
this.showToast(`Found ${data.dead.length} dead screen(s)`, 'warning');
|
||||
await this.loadScreens();
|
||||
this.showToast(`Found ${data.dead.length} dead mux session(s)`, 'warning');
|
||||
await this.loadMuxSessions();
|
||||
} else {
|
||||
this.showToast('All screens are alive', 'success');
|
||||
this.showToast('All mux sessions are alive', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Failed to reconcile screens', 'error');
|
||||
this.showToast('Failed to reconcile mux sessions', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -385,13 +385,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monitor Panel (Screen Sessions + Background Tasks) -->
|
||||
<!-- Monitor Panel (Tmux Sessions + Background Tasks) -->
|
||||
<div class="monitor-panel" id="monitorPanel">
|
||||
<div class="monitor-panel-header" id="monitorPanelHeader">
|
||||
<div class="monitor-panel-title">Monitor</div>
|
||||
<div class="monitor-panel-actions">
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killAllSessions()" title="Kill all sessions and their tmux processes">Kill All</button>
|
||||
<button class="btn-icon-sm" onclick="app.reconcileScreens()" title="Refresh screens">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.reconcileMuxSessions()" title="Refresh tmux sessions">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.toggleMonitorDetach()" title="Detach panel" id="monitorDetachBtn">⧉</button>
|
||||
<button class="btn-icon-sm" onclick="app.toggleMonitorPanel()" title="Toggle panel" id="monitorToggleBtn">▲</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeMonitor()" title="Close monitor">×</button>
|
||||
@@ -401,10 +401,10 @@
|
||||
<div class="monitor-panel-body" id="monitorTabContent">
|
||||
<div class="monitor-section">
|
||||
<div class="monitor-section-header">
|
||||
<span>Screen Sessions</span>
|
||||
<span>Tmux Sessions</span>
|
||||
</div>
|
||||
<div class="monitor-section-body" id="screenSessionsBody">
|
||||
<div class="monitor-empty">No screen sessions</div>
|
||||
<div class="monitor-section-body" id="muxSessionsBody">
|
||||
<div class="monitor-empty">No mux sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="monitor-section" id="monitorPlanAgentsSection" style="display: none;">
|
||||
|
||||
@@ -4626,7 +4626,7 @@ kbd {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* Combined Monitor Panel (Screen Sessions + Background Tasks) */
|
||||
/* Combined Monitor Panel (Tmux Sessions + Background Tasks) */
|
||||
.monitor-panel {
|
||||
position: fixed;
|
||||
bottom: var(--toolbar-height);
|
||||
|
||||
+3
-3
@@ -41,7 +41,7 @@ const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_'];
|
||||
/** Env var keys that are always blocked (security-sensitive) */
|
||||
const BLOCKED_ENV_KEYS = new Set([
|
||||
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
|
||||
'CLAUDEMAN_SCREEN_NAME', 'CLAUDEMAN_TMUX',
|
||||
'CLAUDEMAN_MUX_NAME', 'CLAUDEMAN_TMUX',
|
||||
]);
|
||||
|
||||
/** Validate that an env var key is allowed */
|
||||
@@ -86,7 +86,7 @@ export const RunPromptSchema = z.object({
|
||||
*/
|
||||
export const SessionInputSchema = z.object({
|
||||
input: z.string(),
|
||||
useScreen: z.boolean().optional(),
|
||||
useMux: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -193,7 +193,7 @@ export const SettingsUpdateSchema = z.object({
|
||||
*/
|
||||
export const SessionInputWithLimitSchema = z.object({
|
||||
input: z.string().max(100000), // 100KB max input
|
||||
useScreen: z.boolean().optional(),
|
||||
useMux: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ========== Session Mutation Routes ==========
|
||||
|
||||
+75
-75
@@ -429,18 +429,18 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
this.mux = createMultiplexer();
|
||||
|
||||
// Set up mux event listeners (SSE event names kept as screen:* for frontend compat)
|
||||
// Set up mux event listeners
|
||||
this.mux.on('sessionCreated', (session) => {
|
||||
this.broadcast('screen:created', session);
|
||||
this.broadcast('mux:created', session);
|
||||
});
|
||||
this.mux.on('sessionKilled', (data) => {
|
||||
this.broadcast('screen:killed', data);
|
||||
this.broadcast('mux:killed', data);
|
||||
});
|
||||
this.mux.on('sessionDied', (data) => {
|
||||
this.broadcast('screen:died', data);
|
||||
this.broadcast('mux:died', data);
|
||||
});
|
||||
this.mux.on('statsUpdated', (screens) => {
|
||||
this.broadcast('screen:statsUpdated', screens);
|
||||
this.mux.on('statsUpdated', (sessions) => {
|
||||
this.broadcast('mux:statsUpdated', sessions);
|
||||
});
|
||||
|
||||
// Set up subagent watcher listeners
|
||||
@@ -789,8 +789,8 @@ export class WebServer extends EventEmitter {
|
||||
workingDir,
|
||||
mode: body.mode || 'claude',
|
||||
name: body.name || '',
|
||||
screenManager: this.mux,
|
||||
useScreen: true,
|
||||
mux: this.mux,
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
});
|
||||
|
||||
@@ -820,7 +820,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
|
||||
// Also update the mux session name if applicable
|
||||
this.mux.updateSessionName(id, session.name);
|
||||
this.persistSessionState(session);
|
||||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||||
@@ -854,14 +854,14 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
this.app.delete('/api/sessions/:id', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { killScreen?: string };
|
||||
const killScreen = query.killScreen !== 'false'; // Default to true
|
||||
const query = req.query as { killMux?: string };
|
||||
const killMux = query.killMux !== 'false'; // Default to true
|
||||
|
||||
if (!this.sessions.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
await this.cleanupSession(id, killScreen);
|
||||
await this.cleanupSession(id, killMux);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -1512,7 +1512,7 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
|
||||
// Write Ralph prompt to file in session's working directory
|
||||
// This avoids screen input escaping issues with long multi-line prompts
|
||||
// This avoids mux input escaping issues with long multi-line prompts
|
||||
this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const promptResult = RalphPromptWriteSchema.safeParse(req.body);
|
||||
@@ -1630,14 +1630,14 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
|
||||
// Send input to interactive session
|
||||
// useScreen: true uses writeViaScreen which is more reliable for programmatic input
|
||||
// useMux: true uses writeViaMux which is more reliable for programmatic input
|
||||
this.app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = SessionInputWithLimitSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { input, useScreen } = result.data;
|
||||
const { input, useMux } = result.data;
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
@@ -1649,14 +1649,14 @@ export class WebServer extends EventEmitter {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
|
||||
}
|
||||
|
||||
// Write input to PTY. Direct write is synchronous; writeViaScreen
|
||||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||||
if (useScreen) {
|
||||
if (useMux) {
|
||||
// Fire-and-forget: don't block HTTP response on tmux child process.
|
||||
// Fallback to direct write on failure.
|
||||
session.writeViaScreen(inputStr).then(ok => {
|
||||
session.writeViaMux(inputStr).then(ok => {
|
||||
if (!ok) {
|
||||
console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`);
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
session.write(inputStr);
|
||||
}
|
||||
}).catch(() => {
|
||||
@@ -1766,7 +1766,7 @@ export class WebServer extends EventEmitter {
|
||||
return { success: true, config: controller.getConfig(), active: true };
|
||||
}
|
||||
|
||||
// Return pre-saved config from screens.json
|
||||
// Return pre-saved config from mux-sessions.json
|
||||
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
||||
if (preConfig) {
|
||||
return { success: true, config: preConfig, active: false };
|
||||
@@ -1795,7 +1795,7 @@ export class WebServer extends EventEmitter {
|
||||
// Create or get existing controller
|
||||
let controller = this.respawnControllers.get(id);
|
||||
if (!controller) {
|
||||
// Merge request body with pre-saved config from screens.json
|
||||
// Merge request body with pre-saved config from mux-sessions.json
|
||||
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
||||
const config = body || preConfig ? { ...preConfig, ...body } : undefined;
|
||||
controller = new RespawnController(session, config);
|
||||
@@ -2566,8 +2566,8 @@ export class WebServer extends EventEmitter {
|
||||
const niceConfig = await this.getGlobalNiceConfig();
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
screenManager: this.mux,
|
||||
useScreen: true,
|
||||
mux: this.mux,
|
||||
useMux: true,
|
||||
mode: mode,
|
||||
niceConfig: niceConfig,
|
||||
});
|
||||
@@ -2738,8 +2738,8 @@ 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.mux,
|
||||
useScreen: false, // No screen needed for one-shot
|
||||
mux: this.mux,
|
||||
useMux: false, // No mux needed for one-shot
|
||||
mode: 'claude',
|
||||
});
|
||||
|
||||
@@ -3449,38 +3449,38 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Screen Management Endpoints ============
|
||||
// ============ Mux Session Management Endpoints ============
|
||||
|
||||
// Get all tracked screens with stats
|
||||
this.app.get('/api/screens', async () => {
|
||||
const screens = await this.mux.getSessionsWithStats();
|
||||
// Get all tracked mux sessions with stats
|
||||
this.app.get('/api/mux-sessions', async () => {
|
||||
const sessions = await this.mux.getSessionsWithStats();
|
||||
return {
|
||||
screens,
|
||||
screenAvailable: this.mux.isAvailable()
|
||||
sessions,
|
||||
muxAvailable: this.mux.isAvailable()
|
||||
};
|
||||
});
|
||||
|
||||
// Kill a mux session
|
||||
this.app.delete('/api/screens/:sessionId', async (req) => {
|
||||
this.app.delete('/api/mux-sessions/:sessionId', async (req) => {
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
const success = await this.mux.killSession(sessionId);
|
||||
return { success };
|
||||
});
|
||||
|
||||
// Reconcile screens (find dead ones)
|
||||
this.app.post('/api/screens/reconcile', async () => {
|
||||
// Reconcile mux sessions (find dead ones)
|
||||
this.app.post('/api/mux-sessions/reconcile', async () => {
|
||||
const result = await this.mux.reconcileSessions();
|
||||
return result;
|
||||
});
|
||||
|
||||
// Start stats collection
|
||||
this.app.post('/api/screens/stats/start', async () => {
|
||||
this.app.post('/api/mux-sessions/stats/start', async () => {
|
||||
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Stop stats collection
|
||||
this.app.post('/api/screens/stats/stop', async () => {
|
||||
this.app.post('/api/mux-sessions/stats/stop', async () => {
|
||||
this.mux.stopStatsCollection();
|
||||
return { success: true };
|
||||
});
|
||||
@@ -3891,19 +3891,19 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Track sessions currently being cleaned up to prevent concurrent cleanup races
|
||||
private cleaningUp: Set<string> = new Set();
|
||||
|
||||
private async cleanupSession(sessionId: string, killScreen: boolean = true): Promise<void> {
|
||||
private async cleanupSession(sessionId: string, killMux: boolean = true): Promise<void> {
|
||||
// Guard against concurrent cleanup of the same session
|
||||
if (this.cleaningUp.has(sessionId)) return;
|
||||
this.cleaningUp.add(sessionId);
|
||||
|
||||
try {
|
||||
await this._doCleanupSession(sessionId, killScreen);
|
||||
await this._doCleanupSession(sessionId, killMux);
|
||||
} finally {
|
||||
this.cleaningUp.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async _doCleanupSession(sessionId: string, killScreen: boolean): Promise<void> {
|
||||
private async _doCleanupSession(sessionId: string, killMux: boolean): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
// Stop watching @fix_plan.md for this session
|
||||
@@ -3912,7 +3912,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
// Kill all subagents spawned by this session
|
||||
if (session && killScreen) {
|
||||
if (session && killMux) {
|
||||
try {
|
||||
await subagentWatcher.killSubagentsForSession(session.workingDir);
|
||||
} catch (err) {
|
||||
@@ -3994,7 +3994,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
if (session) {
|
||||
// Accumulate tokens to global stats before removing session
|
||||
// This preserves lifetime usage even after sessions are deleted
|
||||
if (killScreen && (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0)) {
|
||||
if (killMux && (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0)) {
|
||||
this.store.addToGlobalStats(session.inputTokens, session.outputTokens, session.totalCost);
|
||||
// Record to daily stats (for what hasn't been recorded yet via periodic recording)
|
||||
const lastRecorded = this.lastRecordedTokens.get(sessionId) || { input: 0, output: 0 };
|
||||
@@ -4043,11 +4043,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
fileStreamManager.closeSessionStreams(sessionId);
|
||||
// Stop watching for images in this session's directory
|
||||
imageWatcher.unwatchSession(sessionId);
|
||||
await session.stop(killScreen);
|
||||
await session.stop(killMux);
|
||||
this.sessions.delete(sessionId);
|
||||
// Only remove from state.json if we're also killing the screen.
|
||||
// When killScreen=false (server shutdown), preserve state for recovery.
|
||||
if (killScreen) {
|
||||
// Only remove from state.json if we're also killing the mux session.
|
||||
// When killMux=false (server shutdown), preserve state for recovery.
|
||||
if (killMux) {
|
||||
this.store.removeSession(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -4083,7 +4083,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
},
|
||||
|
||||
clearTerminal: () => {
|
||||
// Tell clients to clear their terminal (after screen attach)
|
||||
// Tell clients to clear their terminal (after mux attach)
|
||||
this.broadcast('session:clearTerminal', { id: session.id });
|
||||
},
|
||||
|
||||
@@ -4452,7 +4452,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
*
|
||||
* @param session - The session to attach the controller to
|
||||
* @param config - The persisted respawn configuration
|
||||
* @param source - Source of the config for logging (e.g., 'state.json' or 'screens.json')
|
||||
* @param source - Source of the config for logging (e.g., 'state.json' or 'mux-sessions.json')
|
||||
*/
|
||||
private restoreRespawnController(
|
||||
session: Session,
|
||||
@@ -5074,10 +5074,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// This prevents race conditions where clients connect before state is ready
|
||||
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
|
||||
if (!this.testMode) {
|
||||
await this.restoreScreenSessions();
|
||||
await this.restoreMuxSessions();
|
||||
}
|
||||
|
||||
// Clean up stale sessions from state file that don't have active screens
|
||||
// Clean up stale sessions from state file that don't have active mux sessions
|
||||
this.cleanupStaleSessions();
|
||||
|
||||
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||
@@ -5156,9 +5156,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return false; // Default disabled (matches UI default)
|
||||
}
|
||||
|
||||
private async restoreScreenSessions(): Promise<void> {
|
||||
private async restoreMuxSessions(): Promise<void> {
|
||||
try {
|
||||
// Reconcile screens to find which ones are still alive (also discovers unknown screens)
|
||||
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
|
||||
const { alive, dead, discovered } = await this.mux.reconcileSessions();
|
||||
|
||||
if (discovered.length > 0) {
|
||||
@@ -5168,31 +5168,31 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
if (alive.length > 0 || discovered.length > 0) {
|
||||
console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`);
|
||||
|
||||
// For each alive screen, create a Session object if it doesn't exist
|
||||
const screens = this.mux.getSessions();
|
||||
for (const screen of screens) {
|
||||
if (!this.sessions.has(screen.sessionId)) {
|
||||
// For each alive mux session, create a Session object if it doesn't exist
|
||||
const muxSessions = this.mux.getSessions();
|
||||
for (const muxSession of muxSessions) {
|
||||
if (!this.sessions.has(muxSession.sessionId)) {
|
||||
// Restore session settings from state.json (single source of truth)
|
||||
const savedState = this.store.getSession(screen.sessionId);
|
||||
const savedState = this.store.getSession(muxSession.sessionId);
|
||||
|
||||
// Determine the correct session name (priority: savedState > screen > muxName)
|
||||
// Determine the correct session name (priority: savedState > muxSession > muxName)
|
||||
// This ensures renamed sessions keep their name after server restart
|
||||
const sessionName = savedState?.name || screen.name || screen.muxName;
|
||||
const sessionName = savedState?.name || muxSession.name || muxSession.muxName;
|
||||
|
||||
// Create a session object for this mux session with the existing session
|
||||
// Create a session object for this mux session
|
||||
const session = new Session({
|
||||
id: screen.sessionId, // Preserve the original session ID
|
||||
workingDir: screen.workingDir,
|
||||
mode: screen.mode,
|
||||
id: muxSession.sessionId, // Preserve the original session ID
|
||||
workingDir: muxSession.workingDir,
|
||||
mode: muxSession.mode,
|
||||
name: sessionName,
|
||||
mux: this.mux,
|
||||
useMux: true,
|
||||
muxSession: screen // Pass the existing session so startInteractive() can attach to it
|
||||
muxSession: muxSession // 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.mux.updateSessionName(screen.sessionId, savedState.name);
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
if (savedState?.name && muxSession.name !== savedState.name) {
|
||||
this.mux.updateSessionName(muxSession.sessionId, savedState.name);
|
||||
}
|
||||
if (savedState) {
|
||||
// Auto-compact
|
||||
@@ -5263,18 +5263,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: restore respawn from screens.json if state.json didn't have it
|
||||
if (!this.respawnControllers.has(session.id) && screen.respawnConfig?.enabled) {
|
||||
// Fallback: restore respawn from mux-sessions.json if state.json didn't have it
|
||||
if (!this.respawnControllers.has(session.id) && muxSession.respawnConfig?.enabled) {
|
||||
try {
|
||||
this.restoreRespawnController(session, screen.respawnConfig, 'screens.json');
|
||||
this.restoreRespawnController(session, muxSession.respawnConfig, 'mux-sessions.json');
|
||||
} catch (err) {
|
||||
console.error(`[Server] Failed to restore respawn from screens.json for session ${session.id}:`, err);
|
||||
console.error(`[Server] Failed to restore respawn from mux-sessions.json for session ${session.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled
|
||||
if (!session.ralphTracker.enabled && !session.ralphTracker.autoEnableDisabled) {
|
||||
const ralphState = this.store.getRalphState(screen.sessionId);
|
||||
const ralphState = this.store.getRalphState(muxSession.sessionId);
|
||||
if (ralphState?.loop?.enabled) {
|
||||
session.ralphTracker.restoreState(ralphState.loop, ralphState.todos);
|
||||
console.log(`[Server] Restored Ralph state from inner store for session ${session.id}`);
|
||||
@@ -5296,16 +5296,16 @@ 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 mux ${screen.muxName}`);
|
||||
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start stats collection to show screen info
|
||||
// Start stats collection for mux sessions
|
||||
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
|
||||
// Start mouse mode sync (tmux only) — toggles mouse on/off based on pane count.
|
||||
// Mouse off = native xterm.js selection; mouse on = tmux pane clicking (split layouts).
|
||||
if (this.mux.backend === 'tmux' && 'startMouseModeSync' in this.mux) {
|
||||
if ('startMouseModeSync' in this.mux) {
|
||||
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
|
||||
}
|
||||
}
|
||||
@@ -5415,7 +5415,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
);
|
||||
|
||||
// Properly clean up all remaining sessions in parallel (removes listeners, clears state, etc.)
|
||||
// Don't kill screens on server stop - they can be reattached on restart
|
||||
// Don't kill mux sessions on server stop - they can be reattached on restart
|
||||
// Use Promise.race with a 30s timeout to prevent shutdown from hanging indefinitely
|
||||
const sessionCleanup = Promise.allSettled(
|
||||
Array.from(this.sessions.keys()).map(id => this.cleanupSession(id, false))
|
||||
|
||||
@@ -138,32 +138,32 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Spawn', () => {
|
||||
it('should spawn a screen session for the check', async () => {
|
||||
describe('Tmux Spawn', () => {
|
||||
it('should spawn a tmux session for the check', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('terminal output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
// Verify screen was spawned with correct args
|
||||
// Verify tmux was spawned with correct args
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'screen',
|
||||
expect.arrayContaining(['-dmS', expect.stringContaining('claudeman-aicheck-')]),
|
||||
'tmux',
|
||||
expect.arrayContaining(['new-session', '-d', '-s', expect.stringContaining('claudeman-aicheck-')]),
|
||||
expect.objectContaining({ detached: true, stdio: 'ignore' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should kill existing screen with same name before spawning', async () => {
|
||||
it('should kill existing tmux session with same name before spawning', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
// First call should try to kill existing screen
|
||||
// First call should try to kill existing session
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('screen -X -S "claudeman-aicheck-'),
|
||||
expect.stringContaining('tmux kill-session -t "claudeman-aicheck-'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
@@ -213,7 +213,7 @@ describe('AiIdleChecker', () => {
|
||||
expect(result.reasoning).toBe('Cancelled');
|
||||
});
|
||||
|
||||
it('should clean up screen on cancel', async () => {
|
||||
it('should clean up tmux session on cancel', async () => {
|
||||
mockedReadFileSync.mockReturnValue('');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
@@ -221,9 +221,9 @@ describe('AiIdleChecker', () => {
|
||||
checker.cancel();
|
||||
await checkPromise;
|
||||
|
||||
// Should have tried to kill the screen (initial kill + cleanup kill)
|
||||
// Should have tried to kill the tmux session (initial kill + cleanup kill)
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
call => typeof call[0] === 'string' && call[0].includes('quit')
|
||||
call => typeof call[0] === 'string' && call[0].includes('kill-session')
|
||||
);
|
||||
expect(killCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
+13
-105
@@ -1,21 +1,18 @@
|
||||
/**
|
||||
* @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.
|
||||
* Tests that createMultiplexer returns a TmuxManager when tmux is available,
|
||||
* and throws when tmux is not available.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } 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
|
||||
// Spy on static availability method
|
||||
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 () => {
|
||||
@@ -29,9 +26,6 @@ vi.mock('node:child_process', async () => {
|
||||
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(() => ({
|
||||
@@ -58,107 +52,21 @@ vi.mock('node:fs', async () => {
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
it('should return a TmuxManager when tmux is available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(true);
|
||||
|
||||
const mux = createMultiplexer();
|
||||
expect(mux.backend).toBe('tmux');
|
||||
mux.destroy();
|
||||
});
|
||||
|
||||
describe('auto-detection', () => {
|
||||
it('should prefer tmux when both are available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(true);
|
||||
screenAvailableSpy.mockReturnValue(true);
|
||||
it('should throw when tmux is not available', () => {
|
||||
tmuxAvailableSpy.mockReturnValue(false);
|
||||
|
||||
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');
|
||||
});
|
||||
expect(() => createMultiplexer()).toThrow('tmux not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,17 +300,17 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Screen Sessions API Tests ==========
|
||||
// ========== Mux Sessions API Tests ==========
|
||||
|
||||
describe('Screen Sessions API', () => {
|
||||
it('should return screens object with array', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/screens`);
|
||||
describe('Mux Sessions API', () => {
|
||||
it('should return sessions object with array', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/mux-sessions`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.screens).toBeDefined();
|
||||
expect(Array.isArray(data.screens)).toBe(true);
|
||||
expect(typeof data.screenAvailable).toBe('boolean');
|
||||
expect(data.sessions).toBeDefined();
|
||||
expect(Array.isArray(data.sessions)).toBe(true);
|
||||
expect(typeof data.muxAvailable).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class MockSession extends EventEmitter {
|
||||
this.writeBuffer.push(data);
|
||||
}
|
||||
|
||||
async writeViaScreen(data: string): Promise<boolean> {
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
this.writeBuffer.push(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -93,12 +93,12 @@ export class MockSession extends EventEmitter {
|
||||
writeBuffer: string[] = [];
|
||||
terminalBuffer: string = '';
|
||||
|
||||
private _screenName: string | null = null;
|
||||
private _muxName: string | null = null;
|
||||
|
||||
constructor(id: string = 'mock-session-id') {
|
||||
super();
|
||||
this.id = id;
|
||||
this._screenName = `claudeman-test-${id.slice(0, 8)}`;
|
||||
this._muxName = `claudeman-test-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** Direct PTY write (used by session.write()) */
|
||||
@@ -106,8 +106,8 @@ export class MockSession extends EventEmitter {
|
||||
this.writeBuffer.push(data);
|
||||
}
|
||||
|
||||
/** Write via screen (used by respawn controller) */
|
||||
async writeViaScreen(data: string): Promise<boolean> {
|
||||
/** Write via mux (used by respawn controller) */
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
this.writeBuffer.push(data);
|
||||
return true;
|
||||
}
|
||||
@@ -234,9 +234,9 @@ export class MockSession extends EventEmitter {
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/** Get screen name (for screen-based operations) */
|
||||
get screenName(): string | null {
|
||||
return this._screenName;
|
||||
/** Get mux name (for mux-based operations) */
|
||||
get muxName(): string | null {
|
||||
return this._muxName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,616 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Tests for screen-manager module
|
||||
*
|
||||
* Tests the ScreenManager class which manages GNU screen sessions
|
||||
* for persistent Claude sessions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// We test the validation functions and class behavior without spawning real screens
|
||||
// This is a unit test approach that mocks system dependencies
|
||||
|
||||
describe('ScreenManager', () => {
|
||||
// Test the validation functions (extracted for testing)
|
||||
describe('Screen Name Validation', () => {
|
||||
// Valid screen names should match: claudeman-[a-f0-9-]+
|
||||
const validNames = [
|
||||
'claudeman-abc12345',
|
||||
'claudeman-12345678',
|
||||
'claudeman-a1b2c3d4',
|
||||
'claudeman-aabbccdd',
|
||||
'claudeman-a1b2-c3d4',
|
||||
];
|
||||
|
||||
const invalidNames = [
|
||||
'not-claudeman',
|
||||
'claudeman-ABC123', // uppercase not allowed
|
||||
'claudeman-abc123!', // special chars not allowed
|
||||
'claudeman-abc 123', // spaces not allowed
|
||||
'claudeman-', // empty suffix
|
||||
'CLAUDEMAN-abc123', // uppercase prefix
|
||||
'; rm -rf /', // command injection
|
||||
'claudeman-$(whoami)',
|
||||
'claudeman-`id`',
|
||||
];
|
||||
|
||||
it('should accept valid screen names', () => {
|
||||
const pattern = /^claudeman-[a-f0-9-]+$/;
|
||||
for (const name of validNames) {
|
||||
expect(pattern.test(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid screen names', () => {
|
||||
const pattern = /^claudeman-[a-f0-9-]+$/;
|
||||
for (const name of invalidNames) {
|
||||
expect(pattern.test(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Validation', () => {
|
||||
const validPaths = [
|
||||
'/home/user/project',
|
||||
'/tmp/test',
|
||||
'/var/lib/claudeman',
|
||||
'~/projects/myapp',
|
||||
'/Users/name/Documents',
|
||||
'/path/with-dashes',
|
||||
'/path/with_underscores',
|
||||
'/path/with.dots',
|
||||
'/path with spaces', // Spaces are allowed
|
||||
];
|
||||
|
||||
const invalidPaths = [
|
||||
'/path;rm -rf /', // semicolon injection
|
||||
'/path&& malicious', // command chaining
|
||||
'/path|cat /etc/passwd', // pipe injection
|
||||
'/path$(whoami)', // command substitution
|
||||
'/path`id`', // backtick substitution
|
||||
'/path(test)', // parentheses
|
||||
'/path{a,b}', // brace expansion
|
||||
"/path'test'", // single quotes
|
||||
'/path"test"', // double quotes
|
||||
'/path<>', // redirection
|
||||
'/path\ntest', // newline
|
||||
];
|
||||
|
||||
it('should validate paths without shell metacharacters', () => {
|
||||
const 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;
|
||||
}
|
||||
return /^[a-zA-Z0-9_\/\-. ~]+$/.test(path);
|
||||
};
|
||||
|
||||
for (const path of validPaths) {
|
||||
expect(isValidPath(path)).toBe(true);
|
||||
}
|
||||
|
||||
for (const path of invalidPaths) {
|
||||
expect(isValidPath(path)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Escape Function', () => {
|
||||
const shellEscape = (str: string): string => {
|
||||
return str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, '\\$')
|
||||
.replace(/`/g, '\\`');
|
||||
};
|
||||
|
||||
it('should escape backslashes', () => {
|
||||
expect(shellEscape('path\\file')).toBe('path\\\\file');
|
||||
});
|
||||
|
||||
it('should escape double quotes', () => {
|
||||
expect(shellEscape('say "hello"')).toBe('say \\"hello\\"');
|
||||
});
|
||||
|
||||
it('should escape dollar signs', () => {
|
||||
expect(shellEscape('$HOME/test')).toBe('\\$HOME/test');
|
||||
});
|
||||
|
||||
it('should escape backticks', () => {
|
||||
expect(shellEscape('`whoami`')).toBe('\\`whoami\\`');
|
||||
});
|
||||
|
||||
it('should handle multiple escape characters', () => {
|
||||
expect(shellEscape('$HOME\\file"test"`id`')).toBe('\\$HOME\\\\file\\"test\\"\\`id\\`');
|
||||
});
|
||||
|
||||
it('should not modify safe strings', () => {
|
||||
expect(shellEscape('simple text')).toBe('simple text');
|
||||
expect(shellEscape('/path/to/file')).toBe('/path/to/file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Pattern Matching', () => {
|
||||
const SCREEN_PATTERN = /(\d+)\.(claudeman-([a-f0-9-]+))/g;
|
||||
|
||||
it('should parse screen -ls output correctly', () => {
|
||||
const output = `There are screens on:
|
||||
12345.claudeman-abc12345 (01/15/2024 10:30:00 AM) (Detached)
|
||||
67890.claudeman-def67890 (01/15/2024 11:00:00 AM) (Attached)
|
||||
2 Sockets in /run/screen/S-user.`;
|
||||
|
||||
const matches: Array<{ pid: number; screenName: string; sessionIdFragment: string }> = [];
|
||||
let match;
|
||||
SCREEN_PATTERN.lastIndex = 0;
|
||||
while ((match = SCREEN_PATTERN.exec(output)) !== null) {
|
||||
matches.push({
|
||||
pid: parseInt(match[1], 10),
|
||||
screenName: match[2],
|
||||
sessionIdFragment: match[3],
|
||||
});
|
||||
}
|
||||
|
||||
expect(matches).toHaveLength(2);
|
||||
expect(matches[0].pid).toBe(12345);
|
||||
expect(matches[0].screenName).toBe('claudeman-abc12345');
|
||||
expect(matches[0].sessionIdFragment).toBe('abc12345');
|
||||
expect(matches[1].pid).toBe(67890);
|
||||
expect(matches[1].screenName).toBe('claudeman-def67890');
|
||||
expect(matches[1].sessionIdFragment).toBe('def67890');
|
||||
});
|
||||
|
||||
it('should not match non-claudeman screens', () => {
|
||||
const output = `There are screens on:
|
||||
12345.some-other-screen (01/15/2024 10:30:00 AM) (Detached)
|
||||
67890.another-screen (01/15/2024 11:00:00 AM) (Attached)
|
||||
2 Sockets in /run/screen/S-user.`;
|
||||
|
||||
SCREEN_PATTERN.lastIndex = 0;
|
||||
expect(SCREEN_PATTERN.exec(output)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty screen list', () => {
|
||||
const output = `No Sockets found in /run/screen/S-user.`;
|
||||
|
||||
SCREEN_PATTERN.lastIndex = 0;
|
||||
expect(SCREEN_PATTERN.exec(output)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScreenSession Interface', () => {
|
||||
it('should have all required fields', () => {
|
||||
const session = {
|
||||
sessionId: 'test-session-123',
|
||||
screenName: 'claudeman-abc12345',
|
||||
pid: 12345,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/user/project',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
name: 'My Session',
|
||||
};
|
||||
|
||||
expect(session.sessionId).toBeDefined();
|
||||
expect(session.screenName).toBeDefined();
|
||||
expect(session.pid).toBeGreaterThan(0);
|
||||
expect(session.createdAt).toBeGreaterThan(0);
|
||||
expect(session.workingDir).toBeDefined();
|
||||
expect(['claude', 'shell']).toContain(session.mode);
|
||||
expect(typeof session.attached).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should support shell mode', () => {
|
||||
const session = {
|
||||
sessionId: 'shell-session',
|
||||
screenName: 'claudeman-def67890',
|
||||
pid: 67890,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'shell' as const,
|
||||
attached: true,
|
||||
};
|
||||
|
||||
expect(session.mode).toBe('shell');
|
||||
expect(session.attached).toBe(true);
|
||||
});
|
||||
|
||||
it('should support optional name field', () => {
|
||||
const sessionWithName = {
|
||||
sessionId: 'test-1',
|
||||
screenName: 'claudeman-111',
|
||||
pid: 1111,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
name: 'Custom Name',
|
||||
};
|
||||
|
||||
const sessionWithoutName = {
|
||||
sessionId: 'test-2',
|
||||
screenName: 'claudeman-222',
|
||||
pid: 2222,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
};
|
||||
|
||||
expect(sessionWithName.name).toBe('Custom Name');
|
||||
expect(sessionWithoutName.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should support respawnConfig field', () => {
|
||||
const session = {
|
||||
sessionId: 'respawn-test',
|
||||
screenName: 'claudeman-resp',
|
||||
pid: 9999,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
respawnConfig: {
|
||||
enabled: true,
|
||||
idleTimeoutMs: 5000,
|
||||
updatePrompt: 'continue',
|
||||
interStepDelayMs: 1000,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: '/init',
|
||||
durationMinutes: 60,
|
||||
},
|
||||
};
|
||||
|
||||
expect(session.respawnConfig?.enabled).toBe(true);
|
||||
expect(session.respawnConfig?.durationMinutes).toBe(60);
|
||||
});
|
||||
|
||||
it('should support ralphEnabled field', () => {
|
||||
const session = {
|
||||
sessionId: 'ralph-test',
|
||||
screenName: 'claudeman-ralph',
|
||||
pid: 8888,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: false,
|
||||
ralphEnabled: true,
|
||||
};
|
||||
|
||||
expect(session.ralphEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProcessStats Interface', () => {
|
||||
it('should have all required fields', () => {
|
||||
const stats = {
|
||||
memoryMB: 128.5,
|
||||
cpuPercent: 12.3,
|
||||
childCount: 5,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(stats.memoryMB).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.cpuPercent).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.childCount).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.updatedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const stats = {
|
||||
memoryMB: 0,
|
||||
cpuPercent: 0,
|
||||
childCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(stats.memoryMB).toBe(0);
|
||||
expect(stats.cpuPercent).toBe(0);
|
||||
expect(stats.childCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle high values', () => {
|
||||
const stats = {
|
||||
memoryMB: 8192.5,
|
||||
cpuPercent: 100.0,
|
||||
childCount: 50,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(stats.memoryMB).toBe(8192.5);
|
||||
expect(stats.cpuPercent).toBe(100.0);
|
||||
expect(stats.childCount).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Child PID Extraction', () => {
|
||||
// Simulate pgrep output parsing
|
||||
const parseChildPids = (output: string): number[] => {
|
||||
if (!output.trim()) return [];
|
||||
return output.trim().split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p));
|
||||
};
|
||||
|
||||
it('should parse single child PID', () => {
|
||||
expect(parseChildPids('12345\n')).toEqual([12345]);
|
||||
});
|
||||
|
||||
it('should parse multiple child PIDs', () => {
|
||||
expect(parseChildPids('12345\n67890\n11111\n')).toEqual([12345, 67890, 11111]);
|
||||
});
|
||||
|
||||
it('should handle empty output', () => {
|
||||
expect(parseChildPids('')).toEqual([]);
|
||||
expect(parseChildPids(' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore invalid entries', () => {
|
||||
expect(parseChildPids('12345\nnotapid\n67890\n')).toEqual([12345, 67890]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PS Output Parsing', () => {
|
||||
// Simulate ps output parsing
|
||||
const parsePsOutput = (output: string): Map<number, { memoryMB: number; cpuPercent: number }> => {
|
||||
const result = new Map();
|
||||
for (const line of output.trim().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 (!isNaN(pid)) {
|
||||
result.set(pid, {
|
||||
memoryMB: Math.round(rss / 1024 * 10) / 10,
|
||||
cpuPercent: Math.round(cpu * 10) / 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
it('should parse ps output with single process', () => {
|
||||
const output = '12345 102400 5.5';
|
||||
const result = parsePsOutput(output);
|
||||
expect(result.get(12345)?.memoryMB).toBe(100);
|
||||
expect(result.get(12345)?.cpuPercent).toBe(5.5);
|
||||
});
|
||||
|
||||
it('should parse ps output with multiple processes', () => {
|
||||
const output = `12345 102400 5.5
|
||||
67890 204800 10.0
|
||||
11111 51200 2.5`;
|
||||
const result = parsePsOutput(output);
|
||||
expect(result.size).toBe(3);
|
||||
expect(result.get(12345)?.memoryMB).toBe(100);
|
||||
expect(result.get(67890)?.memoryMB).toBe(200);
|
||||
expect(result.get(11111)?.cpuPercent).toBe(2.5);
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const output = '12345 0 0';
|
||||
const result = parsePsOutput(output);
|
||||
expect(result.get(12345)?.memoryMB).toBe(0);
|
||||
expect(result.get(12345)?.cpuPercent).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty output', () => {
|
||||
expect(parsePsOutput('').size).toBe(0);
|
||||
expect(parsePsOutput(' ').size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Handling for Screen', () => {
|
||||
// Test the logic of splitting input for screen -X stuff commands
|
||||
const parseInput = (input: string): { textPart: string; hasCarriageReturn: boolean } => {
|
||||
const hasCarriageReturn = input.includes('\r');
|
||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '');
|
||||
return { textPart, hasCarriageReturn };
|
||||
};
|
||||
|
||||
it('should detect carriage return', () => {
|
||||
const result = parseInput('test\r');
|
||||
expect(result.hasCarriageReturn).toBe(true);
|
||||
expect(result.textPart).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle text without carriage return', () => {
|
||||
const result = parseInput('test');
|
||||
expect(result.hasCarriageReturn).toBe(false);
|
||||
expect(result.textPart).toBe('test');
|
||||
});
|
||||
|
||||
it('should strip newlines', () => {
|
||||
const result = parseInput('test\n');
|
||||
expect(result.textPart).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
const result = parseInput('');
|
||||
expect(result.textPart).toBe('');
|
||||
expect(result.hasCarriageReturn).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle only carriage return', () => {
|
||||
const result = parseInput('\r');
|
||||
expect(result.textPart).toBe('');
|
||||
expect(result.hasCarriageReturn).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed newlines and carriage returns', () => {
|
||||
const result = parseInput('line1\nline2\r');
|
||||
expect(result.textPart).toBe('line1line2');
|
||||
expect(result.hasCarriageReturn).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Session Management Logic', () => {
|
||||
// Test screen session data structure operations
|
||||
let screens: Map<string, { sessionId: string; screenName: string; pid: number }>;
|
||||
|
||||
beforeEach(() => {
|
||||
screens = new Map();
|
||||
});
|
||||
|
||||
it('should add screen sessions', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
screens.set('session-2', { sessionId: 'session-2', screenName: 'claudeman-222', pid: 222 });
|
||||
|
||||
expect(screens.size).toBe(2);
|
||||
expect(screens.get('session-1')?.pid).toBe(111);
|
||||
});
|
||||
|
||||
it('should remove screen sessions', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
screens.delete('session-1');
|
||||
|
||||
expect(screens.size).toBe(0);
|
||||
expect(screens.get('session-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update screen PID', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
const screen = screens.get('session-1')!;
|
||||
screen.pid = 999;
|
||||
|
||||
expect(screens.get('session-1')?.pid).toBe(999);
|
||||
});
|
||||
|
||||
it('should iterate over screens', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
screens.set('session-2', { sessionId: 'session-2', screenName: 'claudeman-222', pid: 222 });
|
||||
|
||||
const sessionIds: string[] = [];
|
||||
for (const [id] of screens) {
|
||||
sessionIds.push(id);
|
||||
}
|
||||
|
||||
expect(sessionIds).toContain('session-1');
|
||||
expect(sessionIds).toContain('session-2');
|
||||
});
|
||||
|
||||
it('should check if screen exists', () => {
|
||||
screens.set('session-1', { sessionId: 'session-1', screenName: 'claudeman-111', pid: 111 });
|
||||
|
||||
expect(screens.has('session-1')).toBe(true);
|
||||
expect(screens.has('session-2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reconciliation Logic', () => {
|
||||
it('should categorize screens as alive or dead', () => {
|
||||
const knownScreens = new Map([
|
||||
['session-1', { screenName: 'claudeman-111', pid: 111 }],
|
||||
['session-2', { screenName: 'claudeman-222', pid: 222 }],
|
||||
['session-3', { screenName: 'claudeman-333', pid: 333 }],
|
||||
]);
|
||||
|
||||
// Simulate screen -ls showing only some screens are alive
|
||||
const aliveScreenNames = new Set(['claudeman-111', 'claudeman-333']);
|
||||
|
||||
const alive: string[] = [];
|
||||
const dead: string[] = [];
|
||||
|
||||
for (const [sessionId, screen] of knownScreens) {
|
||||
if (aliveScreenNames.has(screen.screenName)) {
|
||||
alive.push(sessionId);
|
||||
} else {
|
||||
dead.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
expect(alive).toEqual(['session-1', 'session-3']);
|
||||
expect(dead).toEqual(['session-2']);
|
||||
});
|
||||
|
||||
it('should discover unknown screens', () => {
|
||||
const knownScreenNames = new Set(['claudeman-111']);
|
||||
const discoveredFromScreenLs = [
|
||||
{ pid: 111, screenName: 'claudeman-111', sessionIdFragment: '111' },
|
||||
{ pid: 222, screenName: 'claudeman-222', sessionIdFragment: '222' },
|
||||
{ pid: 333, screenName: 'claudeman-333', sessionIdFragment: '333' },
|
||||
];
|
||||
|
||||
const discovered: Array<{ screenName: string; sessionIdFragment: string }> = [];
|
||||
|
||||
for (const screen of discoveredFromScreenLs) {
|
||||
if (!knownScreenNames.has(screen.screenName)) {
|
||||
discovered.push({
|
||||
screenName: screen.screenName,
|
||||
sessionIdFragment: screen.sessionIdFragment,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(discovered).toHaveLength(2);
|
||||
expect(discovered[0].screenName).toBe('claudeman-222');
|
||||
expect(discovered[1].screenName).toBe('claudeman-333');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment Variables', () => {
|
||||
it('should format environment variables correctly for claude mode', () => {
|
||||
const sessionId = 'test-session-123';
|
||||
const screenName = 'claudeman-abc12345';
|
||||
|
||||
const envVars = `CLAUDEMAN_SCREEN=1 CLAUDEMAN_SESSION_ID=${sessionId} CLAUDEMAN_SCREEN_NAME=${screenName}`;
|
||||
|
||||
expect(envVars).toContain('CLAUDEMAN_SCREEN=1');
|
||||
expect(envVars).toContain('CLAUDEMAN_SESSION_ID=test-session-123');
|
||||
expect(envVars).toContain('CLAUDEMAN_SCREEN_NAME=claudeman-abc12345');
|
||||
});
|
||||
|
||||
it('should use claude command for claude mode', () => {
|
||||
const mode = 'claude' as const;
|
||||
const cmd = mode === 'claude'
|
||||
? 'claude --dangerously-skip-permissions'
|
||||
: '$SHELL';
|
||||
|
||||
expect(cmd).toBe('claude --dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('should use $SHELL for shell mode', () => {
|
||||
const mode = 'shell' as const;
|
||||
const cmd = mode === 'claude'
|
||||
? 'claude --dangerously-skip-permissions'
|
||||
: '$SHELL';
|
||||
|
||||
expect(cmd).toBe('$SHELL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Name Generation', () => {
|
||||
it('should generate screen name from session ID', () => {
|
||||
const sessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
expect(screenName).toBe('claudeman-a1b2c3d4');
|
||||
});
|
||||
|
||||
it('should handle short session IDs', () => {
|
||||
const sessionId = 'abc';
|
||||
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
expect(screenName).toBe('claudeman-abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Restored Session Naming', () => {
|
||||
it('should create restored session ID', () => {
|
||||
const sessionIdFragment = 'abc12345';
|
||||
const restoredSessionId = `restored-${sessionIdFragment}`;
|
||||
|
||||
expect(restoredSessionId).toBe('restored-abc12345');
|
||||
});
|
||||
|
||||
it('should create restored session name', () => {
|
||||
const screenName = 'claudeman-abc12345';
|
||||
const name = `Restored: ${screenName}`;
|
||||
|
||||
expect(name).toBe('Restored: claudeman-abc12345');
|
||||
});
|
||||
});
|
||||
});
|
||||
+17
-17
@@ -278,37 +278,37 @@ describe('Session State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Session Environment', () => {
|
||||
interface ScreenEnv {
|
||||
CLAUDEMAN_SCREEN: string;
|
||||
describe('Mux Session Environment', () => {
|
||||
interface MuxEnv {
|
||||
CLAUDEMAN_MUX: string;
|
||||
CLAUDEMAN_SESSION_ID: string;
|
||||
CLAUDEMAN_SCREEN_NAME: string;
|
||||
CLAUDEMAN_MUX_NAME: string;
|
||||
}
|
||||
|
||||
const createScreenEnv = (sessionId: string, screenName: string): ScreenEnv => ({
|
||||
CLAUDEMAN_SCREEN: '1',
|
||||
const createMuxEnv = (sessionId: string, muxName: string): MuxEnv => ({
|
||||
CLAUDEMAN_MUX: '1',
|
||||
CLAUDEMAN_SESSION_ID: sessionId,
|
||||
CLAUDEMAN_SCREEN_NAME: screenName,
|
||||
CLAUDEMAN_MUX_NAME: muxName,
|
||||
});
|
||||
|
||||
const isClaudemanSession = (env: Record<string, string | undefined>): boolean => {
|
||||
return env.CLAUDEMAN_SCREEN === '1';
|
||||
return env.CLAUDEMAN_MUX === '1';
|
||||
};
|
||||
|
||||
it('should create valid screen environment', () => {
|
||||
const env = createScreenEnv('session-123', 'claudeman-test');
|
||||
expect(env.CLAUDEMAN_SCREEN).toBe('1');
|
||||
it('should create valid mux environment', () => {
|
||||
const env = createMuxEnv('session-123', 'claudeman-test');
|
||||
expect(env.CLAUDEMAN_MUX).toBe('1');
|
||||
expect(env.CLAUDEMAN_SESSION_ID).toBe('session-123');
|
||||
expect(env.CLAUDEMAN_SCREEN_NAME).toBe('claudeman-test');
|
||||
expect(env.CLAUDEMAN_MUX_NAME).toBe('claudeman-test');
|
||||
});
|
||||
|
||||
it('should detect claudeman session', () => {
|
||||
const env = createScreenEnv('session-123', 'claudeman-test');
|
||||
const env = createMuxEnv('session-123', 'claudeman-test');
|
||||
expect(isClaudemanSession(env)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect non-claudeman session', () => {
|
||||
const env = { CLAUDEMAN_SCREEN: '0' };
|
||||
const env = { CLAUDEMAN_MUX: '0' };
|
||||
expect(isClaudemanSession(env)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -382,7 +382,7 @@ describe('Session State Management', () => {
|
||||
interface CleanupState {
|
||||
buffersCleared: boolean;
|
||||
timersCleared: boolean;
|
||||
screenKilled: boolean;
|
||||
muxKilled: boolean;
|
||||
respawnStopped: boolean;
|
||||
trackerReset: boolean;
|
||||
}
|
||||
@@ -390,7 +390,7 @@ describe('Session State Management', () => {
|
||||
const createCleanupState = (): CleanupState => ({
|
||||
buffersCleared: false,
|
||||
timersCleared: false,
|
||||
screenKilled: false,
|
||||
muxKilled: false,
|
||||
respawnStopped: false,
|
||||
trackerReset: false,
|
||||
});
|
||||
@@ -415,7 +415,7 @@ describe('Session State Management', () => {
|
||||
const state: CleanupState = {
|
||||
buffersCleared: true,
|
||||
timersCleared: true,
|
||||
screenKilled: true,
|
||||
muxKilled: true,
|
||||
respawnStopped: true,
|
||||
trackerReset: true,
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Global test setup for Claudeman tests
|
||||
*
|
||||
* SAFETY: TmuxManager and ScreenManager have built-in test mode detection
|
||||
* SAFETY: TmuxManager has built-in test mode detection
|
||||
* (via process.env.VITEST) that makes ALL shell commands no-ops.
|
||||
* This means tests CANNOT kill, create, or interact with real tmux/screen
|
||||
* This means tests CANNOT kill, create, or interact with real tmux
|
||||
* sessions regardless of what the test code does.
|
||||
*
|
||||
* This setup file only handles mock/timer cleanup between tests.
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('generateClaudeMd', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain('## Claudeman Environment');
|
||||
expect(result).toContain('CLAUDEMAN_SCREEN=1');
|
||||
expect(result).toContain('CLAUDEMAN_MUX=1');
|
||||
});
|
||||
|
||||
it('should include work principles', () => {
|
||||
|
||||
+10
-10
@@ -194,9 +194,9 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(manager.getSession('kill-test')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow kill when session does NOT match CLAUDEMAN_SCREEN_NAME', async () => {
|
||||
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = 'claudeman-0ther1111';
|
||||
it('should allow kill when session does NOT match CLAUDEMAN_MUX_NAME', async () => {
|
||||
const originalEnv = process.env.CLAUDEMAN_MUX_NAME;
|
||||
process.env.CLAUDEMAN_MUX_NAME = 'claudeman-0ther1111';
|
||||
|
||||
try {
|
||||
manager.registerSession({
|
||||
@@ -219,16 +219,16 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(manager.getSession('other-kill-test')).toBeUndefined();
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
delete process.env.CLAUDEMAN_MUX_NAME;
|
||||
} else {
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
|
||||
process.env.CLAUDEMAN_MUX_NAME = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow kill when CLAUDEMAN_SCREEN_NAME is not set', async () => {
|
||||
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
it('should allow kill when CLAUDEMAN_MUX_NAME is not set', async () => {
|
||||
const originalEnv = process.env.CLAUDEMAN_MUX_NAME;
|
||||
delete process.env.CLAUDEMAN_MUX_NAME;
|
||||
|
||||
try {
|
||||
manager.registerSession({
|
||||
@@ -247,9 +247,9 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(result).toBe(true);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
delete process.env.CLAUDEMAN_MUX_NAME;
|
||||
} else {
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
|
||||
process.env.CLAUDEMAN_MUX_NAME = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user