From 7dfd97df5818ddb1702fc5d84b93e95a35b7527a Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 30 Jan 2026 11:30:51 +0100 Subject: [PATCH] chore: bump version to 0.1434 --- CLAUDE.md | 25 +- check-tabs.js | 83 +++++ package.json | 2 +- scripts/screen-chooser.sh | 691 +++++++++++++++++++++++++++++++++++++ src/bash-tool-parser.ts | 32 +- src/file-stream-manager.ts | 4 + src/image-watcher.ts | 28 +- src/ralph-tracker.ts | 18 + src/session.ts | 29 +- src/subagent-watcher.ts | 30 ++ src/task-tracker.ts | 11 + src/web/public/app.js | 166 ++++++++- src/web/public/index.html | 153 ++++---- src/web/public/styles.css | 129 ++++++- src/web/server.ts | 21 +- 15 files changed, 1300 insertions(+), 122 deletions(-) create mode 100644 check-tabs.js create mode 100755 scripts/screen-chooser.sh diff --git a/CLAUDE.md b/CLAUDE.md index 236d2384..697ec3df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `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.1433 (must match `package.json`) +**Version**: 0.1434 (must match `package.json`) ## Project Overview @@ -219,3 +219,26 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base ## Deprecated Code The TUI (Terminal UI) has been removed in favor of the web interface. Files in `src/tui/` are excluded from compilation via `tsconfig.json`. + +## Active Ralph Loop Task + +**Current Task**: built out the mac os installation script for claudeman, check if there is something available in your working directory and then optimize it and make a really clean, easy, simple and fully working installation script for claudeman for mac os x + +**Case Folder**: `/home/arkon/claudeman-cases/claudeman` + +### Key Files +- **Plan Summary**: `/home/arkon/claudeman-cases/claudeman/ralph-wizard/summary.md` - Human-readable plan overview +- **Todo Items**: `/home/arkon/claudeman-cases/claudeman/ralph-wizard/final-result.json` - Contains `items` array with all todo tasks +- **Research**: `/home/arkon/claudeman-cases/claudeman/ralph-wizard/research/result.json` - External resources and codebase patterns + +### How to Work on This Task +1. Read the plan summary to understand the overall approach +2. Check `final-result.json` for the todo items array - each item has `id`, `title`, `description`, `priority` +3. Work through items in priority order (critical → high → medium → low) +4. Use `COMPLETION_PHRASE` when the entire task is complete + +### Research Insights +Check `/home/arkon/claudeman-cases/claudeman/ralph-wizard/research/result.json` for: +- External GitHub repos and documentation links to reference +- Existing codebase patterns to follow +- Technical recommendations from the research phase diff --git a/check-tabs.js b/check-tabs.js new file mode 100644 index 00000000..d0daea19 --- /dev/null +++ b/check-tabs.js @@ -0,0 +1,83 @@ +const { chromium } = require("playwright"); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + page.setDefaultTimeout(10000); + + await page.goto("http://localhost:3199", { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(3000); + + // Click first session tab + await page.locator(".session-tab").first().click(); + await page.waitForTimeout(500); + + // Open session options + await page.locator(".session-tab.active .tab-gear").click(); + await page.waitForTimeout(500); + + // Function to check VISIBILITY of summary elements + async function checkSummaryVisibility(tabName) { + console.log("\n=== " + tabName.toUpperCase() + " TAB ==="); + + // Check if summary-specific elements are VISIBLE (not just in DOM) + + const summarySection = page.locator(".modal:visible #summary-section, .modal:visible [id*='summary']"); + const summaryVisible = await summarySection.first().isVisible().catch(() => false); + + const timelineSection = page.locator(".modal:visible .run-timeline, .modal:visible [class*='timeline']"); + const timelineVisible = await timelineSection.first().isVisible().catch(() => false); + + const filterButtons = page.locator(".modal:visible .filter-btn, .modal:visible [class*='filter']"); + const filterVisible = await filterButtons.first().isVisible().catch(() => false); + + const exportBtns = page.locator(".modal:visible .export-btn, .modal:visible button:has-text('Copy'), .modal:visible button:has-text('Download')"); + const exportVisible = await exportBtns.first().isVisible().catch(() => false); + + console.log(" Summary section visible:", summaryVisible); + console.log(" Timeline visible:", timelineVisible); + console.log(" Filter buttons visible:", filterVisible); + console.log(" Export buttons visible:", exportVisible); + + return { summaryVisible, timelineVisible, filterVisible, exportVisible }; + } + + // Check Respawn tab (default) + const respawn = await checkSummaryVisibility("Respawn"); + + // Check Context tab + await page.locator(".modal:visible .modal-tab-btn[data-tab='context']").click(); + await page.waitForTimeout(200); + const context = await checkSummaryVisibility("Context"); + + // Check Ralph tab + await page.locator(".modal:visible .modal-tab-btn[data-tab='ralph']").click(); + await page.waitForTimeout(200); + const ralph = await checkSummaryVisibility("Ralph"); + + // Check Summary tab + await page.locator(".modal:visible .modal-tab-btn[data-tab='summary']").click(); + await page.waitForTimeout(200); + const summary = await checkSummaryVisibility("Summary"); + + await browser.close(); + + console.log("\n=== FINAL BUG ASSESSMENT ==="); + const hasBugRespawn = respawn.summaryVisible || respawn.timelineVisible; + const hasBugContext = context.summaryVisible || context.timelineVisible; + const hasBugRalph = ralph.summaryVisible || ralph.timelineVisible; + const summaryWorking = summary.summaryVisible || summary.timelineVisible; + + console.log("Respawn tab leaking summary content:", hasBugRespawn ? "YES - BUG EXISTS" : "No - Fixed"); + console.log("Context tab leaking summary content:", hasBugContext ? "YES - BUG EXISTS" : "No - Fixed"); + console.log("Ralph tab leaking summary content:", hasBugRalph ? "YES - BUG EXISTS" : "No - Fixed"); + console.log("Summary tab showing its content:", summaryWorking ? "Yes - Working correctly" : "No - Check if empty or hidden"); + + const bugFixed = (hasBugRespawn === false) && (hasBugContext === false) && (hasBugRalph === false); + if (bugFixed) { + console.log("\n*** BUG IS FIXED - Summary content only appears in Summary tab ***"); + } else { + console.log("\n*** BUG STILL EXISTS - Summary content bleeding to other tabs ***"); + } + +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/package.json b/package.json index 5c2ed1f5..7c52ca93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1433", + "version": "0.1434", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/scripts/screen-chooser.sh b/scripts/screen-chooser.sh new file mode 100755 index 00000000..dbbc753a --- /dev/null +++ b/scripts/screen-chooser.sh @@ -0,0 +1,691 @@ +#!/bin/bash +# ============================================================================ +# Screen Chooser for iPhone/Termius +# Optimized for iPhone 17 Pro (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.sh # Interactive chooser +# ./screen-chooser.sh 1 # Quick attach to session 1 +# ./screen-chooser.sh -l # List only (no interactive) +# ./screen-chooser.sh -h # Help +# +# Alias: alias sc='path/to/screen-chooser.sh' +# 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 +get_session_name() { + local session_id="$1" + + # First, try screens.json (array format) - it has the name field + if [ -f "$CLAUDEMAN_SCREENS" ]; then + local name + name=$(jq -r --arg id "$session_id" ' + .[] | select(.sessionId | startswith($id)) | .name // empty + ' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1) + if [ -n "$name" ] && [ "$name" != "null" ]; then + echo "$name" + return + fi + fi + + # Try state.json with full session ID lookup + if [ -f "$CLAUDEMAN_STATE" ]; then + local name + name=$(jq -r --arg id "$session_id" ' + .sessions | to_entries[] | select(.key | startswith($id)) | .value.name // empty + ' "$CLAUDEMAN_STATE" 2>/dev/null | head -1) + if [ -n "$name" ] && [ "$name" != "null" ]; then + echo "$name" + return + fi + fi + + # Fallback: 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}${ICON_SCREEN} 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}${ICON_SCREEN} 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]}" + + 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 + + # Attach + screen -r "$pid.$name" + + # 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' +Screen Chooser for iPhone/Termius + +USAGE: + sc Interactive chooser + sc Quick attach to session N + sc -l List sessions (non-interactive) + sc -h Show this help + +INTERACTIVE KEYS: + 1-9 Attach to session + n/j/↓ Next page + p/k/↑ Previous page + r Refresh + q Quit + +INDICATORS: + * / ● Attached (someone connected) + - / ○ Detached (available) + R Respawn enabled + 45k Token count + +TIPS: + - Alias: alias sc='path/to/screen-chooser.sh' + - Detach: Ctrl+A D + - Session names from Claudeman state + +EOF +} + +# ============================================================================ +# Main +# ============================================================================ + +main() { + # Argument parsing + case "${1:-}" in + -h|--help) + show_help + exit 0 + ;; + -l|--list) + list_mode + exit 0 + ;; + [1-9]|[1-9][0-9]) + # Quick attach by number + quick_attach "$1" + exit $? + ;; + esac + + # Check dependencies + check_deps + + # Initial render + render + + # Input loop + while true; do + # Read single character without enter + # -s: silent, -n1: one char, -t: timeout for auto-refresh + local timeout_opt="" + if [ "$AUTO_REFRESH" -gt 0 ]; then + timeout_opt="-t $AUTO_REFRESH" + fi + + if read -rsn1 $timeout_opt key 2>/dev/null; then + handle_input "$key" + fi + # Re-render after input or timeout (auto-refresh) + render + done +} + +# Handle Ctrl+C gracefully +trap 'clear_screen; exit 0' INT + +# Run +main "$@" diff --git a/src/bash-tool-parser.ts b/src/bash-tool-parser.ts index b9ae6d73..cb37f8d4 100644 --- a/src/bash-tool-parser.ts +++ b/src/bash-tool-parser.ts @@ -145,8 +145,11 @@ export class BashToolParser extends EventEmitter { private _workingDir: string; private _homeDir: string; - // Track normalized paths to detect equivalents (normalized -> original) - private _normalizedPathMap: Map = new Map(); + // Track auto-remove timers for cleanup + private _autoRemoveTimers: Set> = new Set(); + + // Flag to prevent operations after destroy + private _destroyed: boolean = false; // Debouncing private _pendingUpdate: boolean = false; @@ -395,7 +398,6 @@ export class BashToolParser extends EventEmitter { */ reset(): void { this._activeTools.clear(); - this._normalizedPathMap.clear(); this._lineBuffer = ''; this._lastToolId = null; this.emitUpdate(); @@ -408,7 +410,7 @@ export class BashToolParser extends EventEmitter { * @param data - Raw terminal data (may include ANSI codes) */ processTerminalData(data: string): void { - if (!this._enabled) return; + if (!this._enabled || this._destroyed) return; // Append to line buffer this._lineBuffer += data; @@ -496,10 +498,13 @@ export class BashToolParser extends EventEmitter { this.scheduleUpdate(); // Remove completed tool after a short delay to allow UI to show completion - setTimeout(() => { + const timer = setTimeout(() => { + this._autoRemoveTimers.delete(timer); + if (this._destroyed) return; this._activeTools.delete(tool.id); this.scheduleUpdate(); }, 2000); + this._autoRemoveTimers.add(timer); } this._lastToolId = null; return; @@ -531,10 +536,13 @@ export class BashToolParser extends EventEmitter { this.scheduleUpdate(); // Auto-remove suggestions after 30 seconds - setTimeout(() => { + const timer = setTimeout(() => { + this._autoRemoveTimers.delete(timer); + if (this._destroyed) return; this._activeTools.delete(tool.id); this.scheduleUpdate(); }, 30000); + this._autoRemoveTimers.add(timer); return; } @@ -565,10 +573,13 @@ export class BashToolParser extends EventEmitter { this.scheduleUpdate(); // Auto-remove after 60 seconds - setTimeout(() => { + const timer = setTimeout(() => { + this._autoRemoveTimers.delete(timer); + if (this._destroyed) return; this._activeTools.delete(tool.id); this.scheduleUpdate(); }, 60000); + this._autoRemoveTimers.add(timer); } } @@ -657,12 +668,17 @@ export class BashToolParser extends EventEmitter { * Clean up resources. */ destroy(): void { + this._destroyed = true; if (this._updateTimer) { clearTimeout(this._updateTimer); this._updateTimer = null; } + // Clear all auto-remove timers to prevent orphaned callbacks + for (const timer of this._autoRemoveTimers) { + clearTimeout(timer); + } + this._autoRemoveTimers.clear(); this._activeTools.clear(); - this._normalizedPathMap.clear(); this.removeAllListeners(); } } diff --git a/src/file-stream-manager.ts b/src/file-stream-manager.ts index f7aa8cb8..d1a9bf13 100644 --- a/src/file-stream-manager.ts +++ b/src/file-stream-manager.ts @@ -329,6 +329,10 @@ export class FileStreamManager extends EventEmitter { for (const streamId of this.streams.keys()) { this.closeStream(streamId); } + + // Clear Maps to release references + this.streams.clear(); + this.sessionStreamCounts.clear(); } // ========== Private Methods ========== diff --git a/src/image-watcher.ts b/src/image-watcher.ts index 93bc75ab..15e6f273 100644 --- a/src/image-watcher.ts +++ b/src/image-watcher.ts @@ -67,9 +67,12 @@ export class ImageWatcher extends EventEmitter { /** Map of sessionId -> working directory path */ private sessionDirs = new Map(); - /** Debounce timers for rapid image creation */ + /** Debounce timers for rapid image creation (keyed by filePath) */ private debounceTimers = new Map(); + /** Track which session owns each debounce timer (for cleanup) */ + private timerToSession = new Map(); + /** Whether the watcher is currently running */ private _isRunning = false; @@ -117,6 +120,7 @@ export class ImageWatcher extends EventEmitter { clearTimeout(timer); } this.debounceTimers.clear(); + this.timerToSession.clear(); } /** @@ -199,11 +203,21 @@ export class ImageWatcher extends EventEmitter { } this.sessionDirs.delete(sessionId); - // Clear any pending debounce timer for this session - const timer = this.debounceTimers.get(sessionId); - if (timer) { - clearTimeout(timer); - this.debounceTimers.delete(sessionId); + // Clear any pending debounce timers for this session + // Collect keys first to avoid iterator invalidation during deletion + const toDelete: string[] = []; + for (const [filePath, ownerId] of this.timerToSession) { + if (ownerId === sessionId) { + toDelete.push(filePath); + } + } + for (const filePath of toDelete) { + const timer = this.debounceTimers.get(filePath); + if (timer) { + clearTimeout(timer); + this.debounceTimers.delete(filePath); + } + this.timerToSession.delete(filePath); } } @@ -236,10 +250,12 @@ export class ImageWatcher extends EventEmitter { const timer = setTimeout(() => { this.debounceTimers.delete(filePath); + this.timerToSession.delete(filePath); this.emitImageDetected(sessionId, filePath); }, DEBOUNCE_DELAY_MS); this.debounceTimers.set(filePath, timer); + this.timerToSession.set(filePath, sessionId); } /** diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index b11e15b7..083aaca0 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -1882,6 +1882,8 @@ export class RalphTracker extends EventEmitter { clear(): void { // Clear debounce timers to prevent stale emissions after clear this.clearDebounceTimers(); + // Stop fix plan file watcher to prevent memory leak + this.stopWatchingFixPlan(); this._loopState = createInitialRalphTrackerState(); // This sets enabled: false this._todos.clear(); this._taskNumberToContent.clear(); @@ -2765,4 +2767,20 @@ export class RalphTracker extends EventEmitter { return this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration; } + + /** + * Clean up all resources and release memory. + * + * Call this when the session is being destroyed to prevent memory leaks. + * Stops file watchers, clears all timers, data, and removes event listeners. + */ + destroy(): void { + this.clearDebounceTimers(); + this.stopWatchingFixPlan(); + this._todos.clear(); + this._taskNumberToContent.clear(); + this._completionPhraseCount.clear(); + this._planTasks.clear(); + this.removeAllListeners(); + } } diff --git a/src/session.ts b/src/session.ts index 66566d9a..435fd015 100644 --- a/src/session.ts +++ b/src/session.ts @@ -80,6 +80,14 @@ const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g; // We look for the tool name followed by (description) const TASK_TOOL_PATTERN = /\b(Explore|Task|Bash|Plan|general-purpose)\(([^)]+)\)/g; +// Pre-compiled patterns for hot paths (avoid regex compilation per call) +/** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */ +const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/; +/** Pattern to match Ctrl+L (form feed) characters */ +const CTRL_L_PATTERN = /\x0c/g; +/** Pattern to split by newlines (CR or LF) */ +const NEWLINE_SPLIT_PATTERN = /\r?\n/; + // ============================================================================ // Claude CLI PATH Resolution // ============================================================================ @@ -297,7 +305,6 @@ export class Session extends EventEmitter { private _currentTaskId: string | null = null; // Use BufferAccumulator for hot-path buffers to reduce GC pressure private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE); - private _outputBuffer: string = ''; private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE); private _errorBuffer: string = ''; private _lastActivityAt: number; @@ -469,10 +476,6 @@ export class Session extends EventEmitter { return this._terminalBuffer.value; } - get outputBuffer(): string { - return this._outputBuffer; - } - get textOutput(): string { return this._textOutput.value; } @@ -787,7 +790,6 @@ export class Session extends EventEmitter { this._status = 'busy'; this._terminalBuffer.clear(); - this._outputBuffer = ''; this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; @@ -852,7 +854,7 @@ export class Session extends EventEmitter { // 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( - bufferValue.replace(/^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/, '') + bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '') ); // Signal client to refresh this.emit('clearTerminal'); @@ -916,7 +918,7 @@ export class Session extends EventEmitter { // Filter out focus escape sequences and Ctrl+L (form feed) const data = rawData .replace(FOCUS_ESCAPE_FILTER, '') - .replace(/\x0c/g, ''); // Remove Ctrl+L + .replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L if (!data) return; // Skip if only filtered sequences // BufferAccumulator handles auto-trimming when max size exceeded @@ -1033,7 +1035,6 @@ export class Session extends EventEmitter { this._status = 'busy'; this._terminalBuffer.clear(); - this._outputBuffer = ''; this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; @@ -1198,7 +1199,6 @@ export class Session extends EventEmitter { this._status = 'busy'; this._terminalBuffer.clear(); - this._outputBuffer = ''; this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; @@ -1433,7 +1433,7 @@ export class Session extends EventEmitter { if (!data.includes('(') || !data.includes(')')) return; // Split by newlines and process each line - const lines = data.split(/\r?\n/); + const lines = data.split(NEWLINE_SPLIT_PATTERN); for (const line of lines) { this.parseTaskDescriptionsFromLine(line); } @@ -1803,8 +1803,11 @@ export class Session extends EventEmitter { this._bashToolParser.off('toolsUpdate', this._bashToolHandlers.toolsUpdate); this._bashToolHandlers = null; } - this._bashToolParser.destroy(); + // Destroy all trackers to release memory and stop timers + this._bashToolParser.destroy(); + this._taskTracker.destroy(); + this._ralphTracker.destroy(); } /** @@ -1939,7 +1942,6 @@ export class Session extends EventEmitter { this._currentTaskId = taskId; this._status = 'busy'; this._terminalBuffer.clear(); - this._outputBuffer = ''; this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; @@ -1966,7 +1968,6 @@ export class Session extends EventEmitter { clearBuffers(): void { this._terminalBuffer.clear(); - this._outputBuffer = ''; this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 62b1d421..2de69129 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -128,6 +128,12 @@ const STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older th const STARTUP_MAX_FILE_AGE_MS = 4 * 60 * 60 * 1000; // Only load files modified in last 4 hours on startup const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when exceeded) +// Internal Claude Code agent patterns to filter out (not real user-initiated subagents) +const INTERNAL_AGENT_PATTERNS = [ + /^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode + /^Suggest what user might/i, // Suggestion mode prompt variant +]; + // Display/preview length constants const TEXT_PREVIEW_LENGTH = 200; // Length for text previews in tool results const USER_TEXT_PREVIEW_LENGTH = 80; // Length for user message previews @@ -156,6 +162,15 @@ export class SubagentWatcher extends EventEmitter { super(); } + /** + * Check if a description matches internal Claude Code agent patterns. + * These are not real user-initiated subagents and should be filtered out. + */ + private isInternalAgent(description: string | undefined): boolean { + if (!description) return false; + return INTERNAL_AGENT_PATTERNS.some(pattern => pattern.test(description)); + } + /** * Extract short model identifier from full model name */ @@ -925,6 +940,11 @@ export class SubagentWatcher extends EventEmitter { description = this.extractDescriptionFromFile(filePath); } + // Skip internal Claude Code agents (e.g., suggestion mode) - not real subagents + if (this.isInternalAgent(description)) { + return; + } + const info: SubagentInfo = { agentId, sessionId, @@ -980,6 +1000,11 @@ export class SubagentWatcher extends EventEmitter { extractedDescription = this.extractDescriptionFromFile(filePath); } if (extractedDescription) { + // Check if this is an internal agent - if so, remove it + if (this.isInternalAgent(extractedDescription)) { + this.removeAgent(agentId); + return; + } existingInfo.description = extractedDescription; this.emit('subagent:updated', existingInfo); } @@ -1094,6 +1119,11 @@ export class SubagentWatcher extends EventEmitter { } } if (description) { + // Check if this is an internal agent - if so, remove it + if (this.isInternalAgent(description)) { + this.removeAgent(agentId); + return; + } info.description = description; this.emit('subagent:updated', info); } diff --git a/src/task-tracker.ts b/src/task-tracker.ts index ec356b8c..3f851519 100644 --- a/src/task-tracker.ts +++ b/src/task-tracker.ts @@ -564,4 +564,15 @@ export class TaskTracker extends EventEmitter { this.taskStack = []; this.pendingToolUses.clear(); } + + /** + * Clean up resources and release memory. + * + * Call this when the session is being destroyed to prevent memory leaks. + * Clears all data and removes all event listeners. + */ + destroy(): void { + this.clear(); + this.removeAllListeners(); + } } diff --git a/src/web/public/app.js b/src/web/public/app.js index 8c557061..69d7d1cd 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3482,6 +3482,10 @@ class ClaudemanApp { // Stop any existing generation first this.stopPlanGeneration(); + // Close old plan subagent windows and clear their state + // This ensures clicking on prompts shows fresh data, not cached from previous runs + this.closePlanSubagentWindows(); + // Reset stopped flag to allow new SSE events this.planGenerationStopped = false; @@ -4795,23 +4799,181 @@ class ClaudemanApp { return; } + // Get agent info from planSubagents Map + const agentInfo = this.planSubagents.get(agentId); + const isRunning = agentInfo?.status === 'running'; + const taskDescription = this.ralphWizardConfig?.taskDescription || 'No task description available'; + try { const filePath = `${agentType}/prompt.md`; const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/ralph-wizard/file/${encodeURIComponent(filePath)}`); const data = await res.json(); if (!data.success) { - this.showPlanFileWindow(agentType, 'prompt.md', `Error: ${data.error}`, false); + // File not found - show contextual message based on agent status + if (isRunning) { + const content = this.buildAgentRunningContent(agentType, agentInfo, taskDescription); + this.showPlanFileWindow(agentType, 'prompt.md', content, false); + } else { + // Agent completed but no prompt file - might have failed + const content = this.buildAgentNoPromptContent(agentType, taskDescription); + this.showPlanFileWindow(agentType, 'prompt.md', content, false); + } return; } this.showPlanFileWindow(agentType, 'prompt.md', data.data.content, false); } catch (err) { console.error('Failed to fetch prompt:', err); - this.showPlanFileWindow(agentType, 'prompt.md', `Error fetching prompt: ${err.message}`, false); + if (isRunning) { + const content = this.buildAgentRunningContent(agentType, agentInfo, taskDescription); + this.showPlanFileWindow(agentType, 'prompt.md', content, false); + } else { + this.showPlanFileWindow(agentType, 'prompt.md', `Error fetching prompt: ${err.message}`, false); + } } } + buildAgentRunningContent(agentType, agentInfo, taskDescription) { + const typeDescriptions = { + research: { + title: 'Research Agent', + role: 'Gathering external resources, exploring the codebase, and identifying patterns', + activities: [ + 'Reading CLAUDE.md and project documentation', + 'Exploring the codebase structure with Glob and Grep', + 'Searching the web for relevant documentation and examples', + 'Identifying existing patterns and conventions', + 'Finding similar implementations to reference', + ], + }, + requirements: { + title: 'Requirements Analyst', + role: 'Breaking down the task into concrete, actionable requirements', + activities: [ + 'Analyzing the task description for functional requirements', + 'Identifying non-functional requirements (performance, security)', + 'Detecting edge cases and error scenarios', + 'Defining acceptance criteria for each requirement', + ], + }, + architecture: { + title: 'Architecture Planner', + role: 'Designing the technical approach and file structure', + activities: [ + 'Identifying files to create or modify', + 'Planning the module/component structure', + 'Defining interfaces and data flow', + 'Ensuring consistency with existing architecture', + ], + }, + testing: { + title: 'TDD Specialist', + role: 'Planning the test-first development strategy', + activities: [ + 'Identifying test cases for each requirement', + 'Planning unit, integration, and E2E tests', + 'Defining test fixtures and mocks needed', + 'Ensuring edge cases have test coverage', + ], + }, + risks: { + title: 'Risk Analyst', + role: 'Identifying potential issues and mitigation strategies', + activities: [ + 'Analyzing potential failure points', + 'Identifying security concerns', + 'Flagging performance bottlenecks', + 'Suggesting rollback strategies', + ], + }, + verification: { + title: 'Verification Expert', + role: 'Validating plan completeness and quality', + activities: [ + 'Checking all requirements have implementation steps', + 'Verifying test coverage is adequate', + 'Ensuring dependencies are correctly ordered', + 'Scoring plan quality and completeness', + ], + }, + 'execution-optimizer': { + title: 'Execution Optimizer', + role: 'Optimizing the plan for Claude Code execution', + activities: [ + 'Identifying tasks that can run in parallel', + 'Grouping related tasks for efficiency', + 'Marking fresh context requirements', + 'Estimating token usage per task', + ], + }, + 'final-review': { + title: 'Final Reviewer', + role: 'Performing holistic validation of the complete plan', + activities: [ + 'Checking for gaps or missing steps', + 'Validating task ordering and dependencies', + 'Ensuring the plan is implementable', + 'Final quality assessment', + ], + }, + }; + + const info = typeDescriptions[agentType] || { + title: agentType, + role: 'Processing the task', + activities: ['Working on the task...'], + }; + + const elapsed = agentInfo?.startTime + ? Math.floor((Date.now() - agentInfo.startTime) / 1000) + : 0; + const model = agentInfo?.model || 'unknown'; + + let content = `# ${info.title} - Still Running\n\n`; + content += `⏳ **Status:** Generating... (${elapsed}s elapsed)\n`; + content += `🤖 **Model:** ${model}\n\n`; + content += `---\n\n`; + content += `## Role\n${info.role}\n\n`; + content += `## Currently Working On\n`; + info.activities.forEach(activity => { + content += `- ${activity}\n`; + }); + content += `\n---\n\n`; + content += `## Task Being Analyzed\n\n`; + content += `\`\`\`\n${taskDescription}\n\`\`\`\n\n`; + content += `---\n\n`; + content += `*The full prompt will be available once the agent completes.*\n`; + content += `*Click again after completion to see the actual prompt that was used.*`; + + return content; + } + + buildAgentNoPromptContent(agentType, taskDescription) { + const typeLabels = { + research: 'Research Agent', + requirements: 'Requirements Analyst', + architecture: 'Architecture Planner', + testing: 'TDD Specialist', + risks: 'Risk Analyst', + verification: 'Verification Expert', + 'execution-optimizer': 'Execution Optimizer', + 'final-review': 'Final Reviewer', + }; + + let content = `# ${typeLabels[agentType] || agentType} - No Prompt Available\n\n`; + content += `The prompt file for this agent was not saved.\n\n`; + content += `This can happen if:\n`; + content += `- The agent failed before completing\n`; + content += `- The plan generation was cancelled\n`; + content += `- There was an error saving the output\n\n`; + content += `---\n\n`; + content += `## Task Description\n\n`; + content += `\`\`\`\n${taskDescription}\n\`\`\``; + + return content; + } + async showPlanAgentFiles(agentId, agentType) { const caseName = this.ralphWizardConfig?.caseName; if (!caseName) { diff --git a/src/web/public/index.html b/src/web/public/index.html index 87b2d534..15b04f61 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -426,15 +426,19 @@