Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:17:58 +08:00
commit 72ca635273
15 changed files with 5327 additions and 0 deletions

263
tools/cleanup-sessions.sh Executable file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env bash
#
# cleanup-sessions.sh - Clean up dead or old tmux sessions
#
# Removes dead sessions from the registry or optionally cleans up
# all sessions or sessions older than a specified threshold.
#
# Usage:
# ./cleanup-sessions.sh [options]
#
# Options:
# --dry-run Show what would be cleaned without doing it
# --all Remove all sessions (even alive ones)
# --older-than Remove sessions older than duration (e.g., "1h", "2d")
# -h, --help Show this help message
#
# Exit codes:
# 0 - Success
# 1 - Invalid arguments
set -euo pipefail
# Get script directory to source registry library
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/registry.sh
source "$SCRIPT_DIR/lib/registry.sh"
#------------------------------------------------------------------------------
# Configuration
#------------------------------------------------------------------------------
dry_run=false
clean_all=false
older_than=""
#------------------------------------------------------------------------------
# Functions
#------------------------------------------------------------------------------
usage() {
cat << EOF
Usage: $(basename "$0") [options]
Clean up dead or old tmux sessions from the registry.
Options:
--dry-run Show what would be cleaned without actually removing
--all Remove all sessions (even alive ones)
--older-than DUR Remove sessions older than duration
-h, --help Show this help message
Duration Format:
Supported units: s (seconds), m (minutes), h (hours), d (days)
Examples: 30m, 2h, 1d, 3600s
Cleanup Modes:
Default: Remove only dead/missing/zombie sessions
--all: Remove all sessions regardless of health
--older-than: Remove sessions older than specified duration
Examples:
# Show what dead sessions would be removed (dry-run)
$(basename "$0") --dry-run
# Remove all dead sessions
$(basename "$0")
# Remove sessions inactive for more than 1 hour
$(basename "$0") --older-than 1h
# Remove all sessions (even alive ones)
$(basename "$0") --all
# Dry-run: show sessions older than 2 days
$(basename "$0") --dry-run --older-than 2d
Exit codes:
0 - Success
1 - Invalid arguments
EOF
}
# Parse duration string to seconds
# Args: duration string (e.g., "1h", "30m", "2d")
# Returns: seconds as integer
parse_duration() {
local dur="$1"
if [[ "$dur" =~ ^([0-9]+)([smhd])$ ]]; then
local value="${BASH_REMATCH[1]}"
local unit="${BASH_REMATCH[2]}"
case "$unit" in
s) echo "$value" ;;
m) echo "$((value * 60))" ;;
h) echo "$((value * 3600))" ;;
d) echo "$((value * 86400))" ;;
esac
else
echo "Error: Invalid duration format: $dur" >&2
echo "Use format like: 30m, 2h, 1d" >&2
return 1
fi
}
# Check if session is older than threshold
# Args: created_at timestamp, threshold in seconds
# Returns: 0 if older, 1 if newer
is_older_than() {
local created="$1"
local threshold_secs="$2"
# Convert ISO8601 to epoch (cross-platform)
local created_epoch
created_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$created" "+%s" 2>/dev/null || \
date -d "$created" "+%s" 2>/dev/null || echo "0")
if [[ "$created_epoch" == "0" ]]; then
# Can't parse date, assume it's old
return 0
fi
local now_epoch
now_epoch=$(date "+%s")
local age=$((now_epoch - created_epoch))
[[ $age -gt $threshold_secs ]]
}
#------------------------------------------------------------------------------
# Argument parsing
#------------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
dry_run=true
shift
;;
--all)
clean_all=true
shift
;;
--older-than)
older_than="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: Unknown option: $1" >&2
usage
exit 1
;;
esac
done
#------------------------------------------------------------------------------
# Validate arguments
#------------------------------------------------------------------------------
threshold_secs=0
if [[ -n "$older_than" ]]; then
threshold_secs=$(parse_duration "$older_than") || exit 1
fi
#------------------------------------------------------------------------------
# Get sessions from registry
#------------------------------------------------------------------------------
registry_data=$(registry_list_sessions)
session_names=$(echo "$registry_data" | jq -r '.sessions | keys[]' 2>/dev/null || echo "")
if [[ -z "$session_names" ]]; then
echo "No sessions in registry."
exit 0
fi
#------------------------------------------------------------------------------
# Find sessions to remove
#------------------------------------------------------------------------------
# Path to pane-health tool
PANE_HEALTH="$SCRIPT_DIR/pane-health.sh"
sessions_to_remove=()
removed_count=0
while IFS= read -r name; do
[[ -z "$name" ]] && continue
# Get session data
socket=$(echo "$registry_data" | jq -r ".sessions[\"$name\"].socket")
target=$(echo "$registry_data" | jq -r ".sessions[\"$name\"].target")
created=$(echo "$registry_data" | jq -r ".sessions[\"$name\"].created_at")
# Determine if session should be removed
should_remove=false
reason=""
if [[ "$clean_all" == true ]]; then
should_remove=true
reason="all sessions mode"
else
# Check health status
if [[ -x "$PANE_HEALTH" ]]; then
if ! "$PANE_HEALTH" -S "$socket" -t "$target" --format text >/dev/null 2>&1; then
should_remove=true
reason="dead/missing/zombie"
fi
fi
# Check age if threshold specified
if [[ -n "$older_than" ]] && [[ "$threshold_secs" -gt 0 ]]; then
if is_older_than "$created" "$threshold_secs"; then
should_remove=true
if [[ -n "$reason" ]]; then
reason="$reason + older than $older_than"
else
reason="older than $older_than"
fi
fi
fi
fi
# Add to removal list if needed
if [[ "$should_remove" == true ]]; then
sessions_to_remove+=("$name|$reason")
fi
done <<< "$session_names"
#------------------------------------------------------------------------------
# Remove sessions
#------------------------------------------------------------------------------
if [[ ${#sessions_to_remove[@]} -eq 0 ]]; then
echo "No sessions to clean up."
exit 0
fi
if [[ "$dry_run" == true ]]; then
echo "Dry-run mode: Would remove ${#sessions_to_remove[@]} session(s):"
for session_info in "${sessions_to_remove[@]}"; do
IFS='|' read -r name reason <<< "$session_info"
echo " - $name ($reason)"
done
else
echo "Removing ${#sessions_to_remove[@]} session(s):"
for session_info in "${sessions_to_remove[@]}"; do
IFS='|' read -r name reason <<< "$session_info"
echo " - $name ($reason)"
if registry_remove_session "$name"; then
removed_count=$((removed_count + 1))
else
echo " Warning: Failed to remove $name" >&2
fi
done
echo "Removed $removed_count session(s) successfully."
fi
exit 0

224
tools/create-session.sh Executable file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env bash
#
# create-session.sh - Create and register tmux sessions
#
# Creates a new tmux session and optionally registers it in the session registry.
# Supports launching different types of sessions (Python REPL, gdb, shell).
#
# Usage:
# ./create-session.sh -n <name> [options]
#
# Options:
# -n, --name Session name (required)
# -S, --socket Custom socket path (optional, uses default)
# -w, --window Window name (default: "shell")
# --python Launch Python REPL with PYTHON_BASIC_REPL=1
# --gdb Launch gdb
# --shell Launch shell (default)
# --no-register Don't add to registry
# -h, --help Show this help message
#
# Exit codes:
# 0 - Success
# 1 - Invalid arguments
# 2 - Session already exists
# 3 - Tmux command failed
# 4 - Registry operation failed
set -euo pipefail
# Get script directory to source registry library
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/registry.sh
source "$SCRIPT_DIR/lib/registry.sh"
#------------------------------------------------------------------------------
# Configuration
#------------------------------------------------------------------------------
# Defaults
session_name=""
socket=""
window_name="shell"
session_type="shell"
launch_command="bash"
register_session=true
#------------------------------------------------------------------------------
# Functions
#------------------------------------------------------------------------------
usage() {
cat << EOF
Usage: $(basename "$0") -n <name> [options]
Create a new tmux session and optionally register it in the session registry.
Options:
-n, --name Session name (required)
-S, --socket Custom socket path (optional, uses default)
-w, --window Window name (default: "shell")
--python Launch Python REPL with PYTHON_BASIC_REPL=1
--gdb Launch gdb
--shell Launch shell (default)
--no-register Don't add to registry
-h, --help Show this help message
Session Types:
--shell Launches bash (default)
--python Launches Python REPL with PYTHON_BASIC_REPL=1
--gdb Launches gdb debugger
Examples:
# Create Python REPL session (auto-registered)
$(basename "$0") -n my-python --python
# Create session with custom socket
$(basename "$0") -n my-session -S /tmp/custom.sock --shell
# Create session without registering
$(basename "$0") -n temp-session --no-register
Exit codes:
0 - Success
1 - Invalid arguments
2 - Session already exists
3 - Tmux command failed
4 - Registry operation failed
EOF
}
#------------------------------------------------------------------------------
# Argument parsing
#------------------------------------------------------------------------------
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--name)
session_name="$2"
shift 2
;;
-S|--socket)
socket="$2"
shift 2
;;
-w|--window)
window_name="$2"
shift 2
;;
--python)
session_type="python-repl"
launch_command="PYTHON_BASIC_REPL=1 python3 -q"
shift
;;
--gdb)
session_type="debugger"
launch_command="gdb"
shift
;;
--shell)
session_type="shell"
launch_command="bash"
shift
;;
--no-register)
register_session=false
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: Unknown option: $1" >&2
usage
exit 1
;;
esac
done
#------------------------------------------------------------------------------
# Validation
#------------------------------------------------------------------------------
if [[ -z "$session_name" ]]; then
echo "Error: Session name is required (use -n <name>)" >&2
usage
exit 1
fi
# Set default socket if not provided
if [[ -z "$socket" ]]; then
socket="$CLAUDE_TMUX_SOCKET_DIR/claude.sock"
fi
# Ensure socket directory exists
socket_dir="$(dirname "$socket")"
mkdir -p "$socket_dir"
#------------------------------------------------------------------------------
# Check if session already exists
#------------------------------------------------------------------------------
# Check in registry first
if [[ "$register_session" == true ]] && registry_session_exists "$session_name"; then
echo "Error: Session '$session_name' already exists in registry" >&2
echo "Use a different name or remove the existing session first" >&2
exit 2
fi
# Check if tmux session actually exists on this socket
if tmux -S "$socket" has-session -t "$session_name" 2>/dev/null; then
echo "Error: Tmux session '$session_name' already exists on socket $socket" >&2
echo "Use a different name or kill the existing session first" >&2
exit 2
fi
#------------------------------------------------------------------------------
# Create tmux session
#------------------------------------------------------------------------------
if ! tmux -S "$socket" new-session -d -s "$session_name" -n "$window_name" "$launch_command" 2>/dev/null; then
echo "Error: Failed to create tmux session '$session_name'" >&2
exit 3
fi
# Get the session PID
session_pid=$(tmux -S "$socket" display-message -p -t "$session_name" '#{pane_pid}' 2>/dev/null || echo "")
# Build target (session:window.pane)
target="$session_name:0.0"
#------------------------------------------------------------------------------
# Register session
#------------------------------------------------------------------------------
if [[ "$register_session" == true ]]; then
if ! registry_add_session "$session_name" "$socket" "$target" "$session_type" "$session_pid"; then
echo "Warning: Session created but failed to register in registry" >&2
# Don't fail completely, session was created successfully
fi
fi
#------------------------------------------------------------------------------
# Output session info as JSON
#------------------------------------------------------------------------------
cat << EOF
{
"name": "$session_name",
"socket": "$socket",
"target": "$target",
"type": "$session_type",
"pid": ${session_pid:-null},
"window": "$window_name",
"registered": $register_session
}
EOF
exit 0

262
tools/find-sessions.sh Executable file
View File

@@ -0,0 +1,262 @@
#!/usr/bin/env bash
#
# find-sessions.sh - Discover and list tmux sessions on sockets
#
# PURPOSE:
# Find and display information about tmux sessions, either on a specific
# socket or by scanning all sockets in a directory. Useful for discovering
# what agent sessions are currently running.
#
# HOW IT WORKS:
# 1. Identify target socket(s) based on command-line options
# 2. Query each socket for running tmux sessions
# 3. Display session info: name, attach status, creation time
# 4. Optionally filter by session name substring
#
# USE CASES:
# - List all agent sessions across multiple sockets
# - Find a specific session by name (partial matching)
# - Check if a session is attached or detached
# - See when sessions were created
# - Enumerate sessions before cleanup
#
# EXAMPLES:
# # List sessions on default tmux socket
# ./find-sessions.sh
#
# # List sessions on specific socket by name
# ./find-sessions.sh -L mysocket
#
# # List sessions on specific socket by path
# ./find-sessions.sh -S /tmp/claude-tmux-sockets/claude.sock
#
# # Scan all sockets in directory
# ./find-sessions.sh --all
#
# # Find sessions with "python" in the name
# ./find-sessions.sh --all -q python
#
# DEPENDENCIES:
# - bash (with arrays, [[, functions)
# - tmux (for list-sessions)
# - grep (for filtering by query)
#
# Bash strict mode:
# -e: Exit immediately if any command fails
# -u: Treat unset variables as errors
# -o pipefail: Pipe fails if any command in pipeline fails
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: find-sessions.sh [-L socket-name|-S socket-path|-A] [-q pattern]
List tmux sessions on a socket (default tmux socket if none provided).
Options:
-L, --socket tmux socket name (passed to tmux -L)
-S, --socket-path tmux socket path (passed to tmux -S)
-A, --all scan all sockets under CLAUDE_TMUX_SOCKET_DIR
-q, --query case-insensitive substring to filter session names
-h, --help show this help
USAGE
}
# ============================================================================
# Default Configuration
# ============================================================================
# Socket specification (mutually exclusive)
socket_name="" # tmux socket name (for tmux -L)
socket_path="" # tmux socket path (for tmux -S)
# Filtering and scanning options
query="" # substring to filter session names (case-insensitive)
scan_all=false # whether to scan all sockets in socket_dir
# Directory containing agent tmux sockets
# Priority: CLAUDE_TMUX_SOCKET_DIR env var > TMPDIR/claude-tmux-sockets > /tmp/claude-tmux-sockets
socket_dir="${CLAUDE_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/claude-tmux-sockets}"
# ============================================================================
# Parse Command-Line Arguments
# ============================================================================
while [[ $# -gt 0 ]]; do
case "$1" in
-L|--socket) socket_name="${2-}"; shift 2 ;; # Socket name mode
-S|--socket-path) socket_path="${2-}"; shift 2 ;; # Socket path mode
-A|--all) scan_all=true; shift ;; # Scan all mode
-q|--query) query="${2-}"; shift 2 ;; # Filter by name
-h|--help) usage; exit 0 ;; # Show help
*) echo "Unknown option: $1" >&2; usage; exit 1 ;; # Error on unknown
esac
done
# ============================================================================
# Validate Options
# ============================================================================
# Cannot use --all with specific socket options (they're mutually exclusive)
if [[ "$scan_all" == true && ( -n "$socket_name" || -n "$socket_path" ) ]]; then
echo "Cannot combine --all with -L or -S" >&2
exit 1
fi
# Cannot use both -L and -S at the same time (different socket types)
if [[ -n "$socket_name" && -n "$socket_path" ]]; then
echo "Use either -L or -S, not both" >&2
exit 1
fi
# Check that tmux is installed and available in PATH
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux not found in PATH" >&2
exit 1
fi
# ============================================================================
# Function: list_sessions
# ============================================================================
# Query a tmux socket for sessions and display formatted output
#
# Arguments:
# $1: Label describing the socket (for display purposes)
# $@: Remaining args are passed to tmux command (e.g., -L name or -S path)
#
# Returns:
# 0 if sessions found (or no sessions after filtering)
# 1 if tmux server not running on this socket
#
# Output format:
# Sessions on <label>:
# - session-name (attached|detached, started <timestamp>)
#
list_sessions() {
# Store label for display, then shift to get remaining args
local label="$1"; shift
# Build tmux command array with remaining args (socket options)
local tmux_cmd=(tmux "$@")
# --------------------------------------------------------------------------
# Query tmux for session information
# --------------------------------------------------------------------------
# tmux list-sessions -F specifies output format:
# #{session_name}: Name of the session
# #{session_attached}: 1 if attached, 0 if detached
# #{session_created_string}: Human-readable creation timestamp
# Tab-separated output for easy parsing
# 2>/dev/null: Suppress errors if no server running
# if !: Check if command failed (no server = exit code 1)
#
if ! sessions="$("${tmux_cmd[@]}" list-sessions -F '#{session_name}\t#{session_attached}\t#{session_created_string}' 2>/dev/null)"; then
echo "No tmux server found on $label" >&2
return 1
fi
# --------------------------------------------------------------------------
# Filter sessions by query if provided
# --------------------------------------------------------------------------
# -i: Case-insensitive search
# --: End of options (allows query starting with -)
# || true: Don't fail if grep finds no matches (returns exit 1)
#
if [[ -n "$query" ]]; then
sessions="$(printf '%s\n' "$sessions" | grep -i -- "$query" || true)"
fi
# --------------------------------------------------------------------------
# Handle case where no sessions match query
# --------------------------------------------------------------------------
if [[ -z "$sessions" ]]; then
echo "No sessions found on $label"
return 0
fi
# --------------------------------------------------------------------------
# Format and display session information
# --------------------------------------------------------------------------
echo "Sessions on $label:"
# Parse tab-separated values into name, attached, created
# IFS=$'\t': Set field separator to tab character
# read -r: Don't interpret backslashes (raw input)
printf '%s\n' "$sessions" | while IFS=$'\t' read -r name attached created; do
# Convert attached flag (1/0) to human-readable label
attached_label=$([[ "$attached" == "1" ]] && echo "attached" || echo "detached")
# Display formatted session info
printf ' - %s (%s, started %s)\n' "$name" "$attached_label" "$created"
done
}
# ============================================================================
# Main Execution: Scan All Mode
# ============================================================================
# Scan all socket files in socket_dir and list sessions on each
if [[ "$scan_all" == true ]]; then
# Verify socket directory exists
if [[ ! -d "$socket_dir" ]]; then
echo "Socket directory not found: $socket_dir" >&2
exit 1
fi
# --------------------------------------------------------------------------
# Enumerate all files in socket directory
# --------------------------------------------------------------------------
# shopt -s nullglob: If no matches, glob expands to empty array (not literal *)
# This prevents errors when directory is empty
shopt -s nullglob
sockets=("$socket_dir"/*)
shopt -u nullglob # Restore default behavior
# Check if any files were found
if [[ "${#sockets[@]}" -eq 0 ]]; then
echo "No sockets found under $socket_dir" >&2
exit 1
fi
# --------------------------------------------------------------------------
# Iterate through all socket files and list sessions
# --------------------------------------------------------------------------
# Track exit code: 0 = all succeeded, 1 = at least one failed
exit_code=0
for sock in "${sockets[@]}"; do
# -S test: Check if file is a socket (not a regular file or directory)
# Skip non-socket files (e.g., .DS_Store, temp files)
if [[ ! -S "$sock" ]]; then
continue
fi
# Call list_sessions for this socket
# || exit_code=$?: Capture failure exit code but continue loop
list_sessions "socket path '$sock'" -S "$sock" || exit_code=$?
done
# Exit with captured exit code (0 if all succeeded, 1 if any failed)
exit "$exit_code"
fi
# ============================================================================
# Main Execution: Single Socket Mode
# ============================================================================
# List sessions on a specific socket (or default socket)
# Start with base tmux command
tmux_cmd=(tmux)
socket_label="default socket"
# Add socket-specific options based on user input
if [[ -n "$socket_name" ]]; then
# -L mode: Named socket (e.g., tmux -L mysocket)
tmux_cmd+=(-L "$socket_name")
socket_label="socket name '$socket_name'"
elif [[ -n "$socket_path" ]]; then
# -S mode: Socket path (e.g., tmux -S /tmp/my.sock)
tmux_cmd+=(-S "$socket_path")
socket_label="socket path '$socket_path'"
fi
# If neither set, use default tmux socket (no additional flags)
# Call list_sessions with constructed command
# ${tmux_cmd[@]:1}: Array slice starting at index 1 (skips "tmux" itself)
# This passes only the flags (e.g., "-L mysocket" or "-S /path")
list_sessions "$socket_label" "${tmux_cmd[@]:1}"

308
tools/kill-session.sh Executable file
View File

@@ -0,0 +1,308 @@
#!/usr/bin/env bash
#
# kill-session.sh - Kill tmux session and remove from registry
#
# PURPOSE:
# Atomically kill a tmux session and remove it from the session registry.
# Provides a single operation to fully clean up a session.
#
# USAGE:
# ./kill-session.sh [options]
#
# OPTIONS:
# -s, --session NAME Session name (uses registry lookup)
# -S, --socket PATH Socket path (explicit mode, requires -t)
# -t, --target TARGET Target pane (explicit mode, requires -S)
# --dry-run Show what would be done without executing
# -v, --verbose Verbose output
# -h, --help Show this help message
#
# EXIT CODES:
# 0 - Complete success (tmux session killed AND deregistered)
# 1 - Partial success (one operation succeeded, one failed)
# 2 - Complete failure (both operations failed or session not found)
# 3 - Invalid arguments
#
# EXAMPLES:
# # Kill session by name (registry lookup)
# ./kill-session.sh -s claude-python
#
# # Kill with explicit socket/target
# ./kill-session.sh -S /tmp/claude.sock -t my-session:0.0
#
# # Dry-run to see what would happen
# ./kill-session.sh -s claude-python --dry-run
#
# # Auto-detect single session
# ./kill-session.sh
#
# DEPENDENCIES:
# - bash, tmux, jq
# - lib/registry.sh (for session registry operations)
#
set -euo pipefail
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source registry library
# shellcheck source=lib/registry.sh
source "$SCRIPT_DIR/lib/registry.sh"
#------------------------------------------------------------------------------
# Configuration
#------------------------------------------------------------------------------
session_name=""
socket=""
target=""
dry_run=false
verbose=false
#------------------------------------------------------------------------------
# Functions
#------------------------------------------------------------------------------
usage() {
cat <<'EOF'
Usage: kill-session.sh [options]
Kill tmux session and remove from registry (atomic operation).
Options:
-s, --session NAME Session name (uses registry lookup)
-S, --socket PATH Socket path (explicit mode, requires -t)
-t, --target TARGET Target pane (explicit mode, requires -S)
--dry-run Show what would be done without executing
-v, --verbose Verbose output
-h, --help Show this help message
Exit codes:
0 - Complete success (killed AND deregistered)
1 - Partial success (one operation succeeded)
2 - Complete failure (both failed or not found)
3 - Invalid arguments
Examples:
# Kill session by name
kill-session.sh -s claude-python
# Kill with explicit socket/target
kill-session.sh -S /tmp/claude.sock -t session:0.0
# Dry-run
kill-session.sh -s claude-python --dry-run
# Auto-detect (if only one session exists)
kill-session.sh
Priority order (if multiple methods specified):
1. Explicit -S and -t (highest priority)
2. Session name -s (registry lookup)
3. Auto-detect (if no flags and only one session exists)
EOF
}
log_verbose() {
if [[ "$verbose" == true ]]; then
echo "$@" >&2
fi
}
# Auto-detect session if only one exists in registry
# Sets session_name global variable
# Returns: 0 if detected, 1 if cannot auto-detect
auto_detect_session() {
local registry_data session_count session_names
registry_data=$(registry_list_sessions)
session_names=$(echo "$registry_data" | jq -r '.sessions | keys[]' 2>/dev/null || echo "")
if [[ -z "$session_names" ]]; then
echo "Error: No sessions in registry" >&2
return 1
fi
session_count=$(echo "$session_names" | wc -l | tr -d ' ')
if [[ "$session_count" -eq 1 ]]; then
session_name="$session_names"
log_verbose "Auto-detected session: $session_name"
return 0
else
echo "Error: Multiple sessions found, specify -s session-name:" >&2
# shellcheck disable=SC2001 # sed needed for adding prefix to multiple lines
echo "$session_names" | sed 's/^/ - /' >&2
return 1
fi
}
#------------------------------------------------------------------------------
# Argument parsing
#------------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
-s|--session)
session_name="${2:-}"
if [[ -z "$session_name" ]]; then
echo "Error: -s requires a session name" >&2
exit 3
fi
shift 2
;;
-S|--socket)
socket="${2:-}"
if [[ -z "$socket" ]]; then
echo "Error: -S requires a socket path" >&2
exit 3
fi
shift 2
;;
-t|--target)
target="${2:-}"
if [[ -z "$target" ]]; then
echo "Error: -t requires a target pane" >&2
exit 3
fi
shift 2
;;
--dry-run)
dry_run=true
shift
;;
-v|--verbose)
verbose=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: Unknown option: $1" >&2
usage
exit 3
;;
esac
done
#------------------------------------------------------------------------------
# Validate arguments
#------------------------------------------------------------------------------
# Priority 1: Explicit socket/target mode
if [[ -n "$socket" || -n "$target" ]]; then
if [[ -z "$socket" || -z "$target" ]]; then
echo "Error: Both -S and -t must be specified together" >&2
exit 3
fi
log_verbose "Using explicit mode: socket=$socket, target=$target"
# Priority 2: Session name mode (registry lookup)
elif [[ -n "$session_name" ]]; then
log_verbose "Using registry mode: session=$session_name"
# Look up socket and target from registry
if ! session_data=$(registry_get_session "$session_name" 2>/dev/null); then
echo "Error: Session '$session_name' not found in registry" >&2
exit 2
fi
socket=$(echo "$session_data" | jq -r '.socket')
target=$(echo "$session_data" | jq -r '.target')
if [[ -z "$socket" || -z "$target" ]]; then
echo "Error: Invalid session data in registry for '$session_name'" >&2
exit 2
fi
log_verbose "Resolved from registry: socket=$socket, target=$target"
# Priority 3: Auto-detect mode
else
log_verbose "Attempting auto-detect..."
if ! auto_detect_session; then
exit 2
fi
# Look up socket and target
session_data=$(registry_get_session "$session_name")
socket=$(echo "$session_data" | jq -r '.socket')
target=$(echo "$session_data" | jq -r '.target')
log_verbose "Auto-detected: socket=$socket, target=$target"
fi
# Extract session name from target if not already set
if [[ -z "$session_name" ]]; then
# Target format is typically "session-name:0.0"
session_name="${target%%:*}"
fi
#------------------------------------------------------------------------------
# Execute kill operations
#------------------------------------------------------------------------------
if [[ "$dry_run" == true ]]; then
echo "Dry-run mode: Would perform the following operations:"
echo " 1. Kill tmux session: tmux -S \"$socket\" kill-session -t \"$session_name\""
if registry_session_exists "$session_name"; then
echo " 2. Remove from registry: $session_name"
else
echo " 2. Session not in registry, skip deregistration"
fi
exit 0
fi
#------------------------------------------------------------------------------
# Actual execution
#------------------------------------------------------------------------------
tmux_killed=false
registry_removed=false
# Step 1: Kill tmux session
echo "Killing tmux session: $session_name"
if tmux -S "$socket" kill-session -t "$session_name" 2>/dev/null; then
echo " ✓ Tmux session killed successfully"
tmux_killed=true
log_verbose "Tmux kill-session succeeded"
else
exit_code=$?
echo " ✗ Failed to kill tmux session (exit code: $exit_code)" >&2
log_verbose "Tmux kill-session failed, session may not exist"
fi
# Step 2: Remove from registry
if registry_session_exists "$session_name"; then
echo "Removing from registry: $session_name"
if registry_remove_session "$session_name"; then
echo " ✓ Removed from registry successfully"
registry_removed=true
log_verbose "Registry removal succeeded"
else
echo " ✗ Failed to remove from registry" >&2
log_verbose "Registry removal failed"
fi
else
log_verbose "Session not in registry, skipping deregistration"
# Not in registry is OK if we killed the tmux session
registry_removed=true
fi
#------------------------------------------------------------------------------
# Determine final exit code
#------------------------------------------------------------------------------
if [[ "$tmux_killed" == true && "$registry_removed" == true ]]; then
echo "Session '$session_name' fully removed"
exit 0
elif [[ "$tmux_killed" == true || "$registry_removed" == true ]]; then
echo "Warning: Partial removal of session '$session_name'" >&2
exit 1
else
echo "Error: Failed to remove session '$session_name'" >&2
exit 2
fi

255
tools/list-sessions.sh Executable file
View File

@@ -0,0 +1,255 @@
#!/usr/bin/env bash
#
# list-sessions.sh - List all registered tmux sessions
#
# Lists all sessions in the registry with health status information.
# Supports both human-readable table format and JSON output.
#
# Usage:
# ./list-sessions.sh [--json]
#
# Options:
# --json Output as JSON instead of table
# -h, --help Show this help message
#
# Exit codes:
# 0 - Success
# 1 - Error
set -euo pipefail
# Get script directory to source libraries
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/registry.sh
source "$SCRIPT_DIR/lib/registry.sh"
# shellcheck source=lib/time_utils.sh
source "$SCRIPT_DIR/lib/time_utils.sh"
# Path to pane-health tool
PANE_HEALTH="$SCRIPT_DIR/pane-health.sh"
#------------------------------------------------------------------------------
# Configuration
#------------------------------------------------------------------------------
output_format="table"
#------------------------------------------------------------------------------
# Functions
#------------------------------------------------------------------------------
usage() {
cat << EOF
Usage: $(basename "$0") [--json]
List all registered tmux sessions with health status.
Options:
--json Output as JSON instead of table format
-h, --help Show this help message
Output Formats:
Table (default):
NAME SOCKET TARGET STATUS PID CREATED
my-python claude.sock my-python:0.0 alive 1234 2h ago
my-gdb claude.sock my-gdb:0.0 dead - 1h ago
JSON (--json):
{
"sessions": [
{"name": "my-python", "socket": "...", "status": "alive", ...}
],
"total": 2,
"alive": 1,
"dead": 1
}
Health Status:
alive - Session is running and healthy
dead - Pane is marked as dead
missing - Session/pane not found
zombie - Process not running
server - Tmux server not running
Examples:
# List sessions in table format
$(basename "$0")
# List sessions as JSON
$(basename "$0") --json
Exit codes:
0 - Success
1 - Error
EOF
}
# Get health status for a session
# Returns: status string (alive, dead, missing, zombie, server)
get_health_status() {
local socket="$1"
local target="$2"
if [[ ! -x "$PANE_HEALTH" ]]; then
echo "unknown"
return
fi
# Call pane-health.sh and interpret exit code
if "$PANE_HEALTH" -S "$socket" -t "$target" --format text >/dev/null 2>&1; then
echo "alive"
else
local exit_code=$?
case $exit_code in
1) echo "dead" ;;
2) echo "missing" ;;
3) echo "zombie" ;;
4) echo "server" ;;
*) echo "unknown" ;;
esac
fi
}
#------------------------------------------------------------------------------
# Argument parsing
#------------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
output_format="json"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: Unknown option: $1" >&2
usage
exit 1
;;
esac
done
#------------------------------------------------------------------------------
# Get sessions from registry
#------------------------------------------------------------------------------
registry_data=$(registry_list_sessions)
session_names=$(echo "$registry_data" | jq -r '.sessions | keys[]' 2>/dev/null || echo "")
# Count sessions
total_count=0
alive_count=0
dead_count=0
# Build session list with health info
sessions_with_health=()
if [[ -n "$session_names" ]]; then
while IFS= read -r name; do
[[ -z "$name" ]] && continue
# Get session data
socket=$(echo "$registry_data" | jq -r ".sessions[\"$name\"].socket")
target=$(echo "$registry_data" | jq -r ".sessions[\"$name\"].target")
type=$(echo "$registry_data" | jq -r ".sessions[\"$name\"].type")
pid=$(echo "$registry_data" | jq -r ".sessions[\"$name\"].pid // \"\"")
created=$(echo "$registry_data" | jq -r ".sessions[\"$name\"].created_at")
# Get health status
status=$(get_health_status "$socket" "$target")
# Update counters
total_count=$((total_count + 1))
if [[ "$status" == "alive" ]]; then
alive_count=$((alive_count + 1))
else
dead_count=$((dead_count + 1))
fi
# Store session info
sessions_with_health+=("$name|$socket|$target|$status|$pid|$created|$type")
done <<< "$session_names"
fi
#------------------------------------------------------------------------------
# Output results
#------------------------------------------------------------------------------
if [[ "$output_format" == "json" ]]; then
# JSON output
echo "{"
echo " \"sessions\": ["
first=true
for session_info in "${sessions_with_health[@]+"${sessions_with_health[@]}"}"; do
IFS='|' read -r name socket target status pid created type <<< "$session_info"
if [[ "$first" == false ]]; then
echo ","
fi
first=false
# Get basename of socket for cleaner output
socket_basename=$(basename "$socket")
cat << EOF
{
"name": "$name",
"socket": "$socket",
"socket_basename": "$socket_basename",
"target": "$target",
"type": "$type",
"status": "$status",
"pid": ${pid:-null},
"created_at": "$created"
}
EOF
done
echo ""
echo " ],"
echo " \"total\": $total_count,"
echo " \"alive\": $alive_count,"
echo " \"dead\": $dead_count"
echo "}"
else
# Table output
if [[ $total_count -eq 0 ]]; then
echo "No sessions registered."
exit 0
fi
# Print header
printf "%-20s %-20s %-20s %-10s %-8s %-15s\n" \
"NAME" "SOCKET" "TARGET" "STATUS" "PID" "CREATED"
printf "%-20s %-20s %-20s %-10s %-8s %-15s\n" \
"----" "------" "------" "------" "---" "-------"
# Print sessions
for session_info in "${sessions_with_health[@]+"${sessions_with_health[@]}"}"; do
IFS='|' read -r name socket target status pid created type <<< "$session_info"
# Get basename of socket for cleaner output
socket_basename=$(basename "$socket")
# Format time ago
time_str=$(time_ago "$created")
# Truncate long values
name_trunc="${name:0:20}"
socket_trunc="${socket_basename:0:20}"
target_trunc="${target:0:20}"
printf "%-20s %-20s %-20s %-10s %-8s %-15s\n" \
"$name_trunc" "$socket_trunc" "$target_trunc" "$status" "${pid:--}" "$time_str"
done
# Print summary
echo ""
echo "Total: $total_count | Alive: $alive_count | Dead: $dead_count"
fi
exit 0

424
tools/pane-health.sh Executable file
View File

@@ -0,0 +1,424 @@
#!/usr/bin/env bash
#
# pane-health.sh - Check health status of a tmux pane
#
# PURPOSE:
# Verify pane and session state before operations to prevent "pane not found"
# errors and detect failures early. Essential for reliable tmux automation.
#
# HOW IT WORKS:
# 1. Check if tmux server is running on the specified socket
# 2. Verify session exists using tmux has-session
# 3. Check if pane exists and get its state (dead/alive, PID)
# 4. Validate process is running via ps command
# 5. Determine overall health status and return structured output
#
# USE CASES:
# - Before sending commands: verify pane is ready
# - After errors: determine if pane crashed
# - Periodic health checks during long operations
# - Cleanup decision: which panes to kill vs keep
# - Integration with other tools (safe-send.sh, etc.)
#
# EXAMPLES:
# # Check pane health in JSON format
# ./pane-health.sh -S /tmp/my.sock -t session:0.0
#
# # Check pane health in text format
# ./pane-health.sh -t myapp:0.0 --format text
#
# # Use in conditional logic
# if ./pane-health.sh -t session:0.0 --format text; then
# echo "Pane is healthy"
# else
# echo "Pane has issues (exit code: $?)"
# fi
#
# EXIT CODES:
# 0 - Healthy (pane alive, process running)
# 1 - Dead (pane marked as dead)
# 2 - Missing (pane/session doesn't exist)
# 3 - Zombie (process exited but pane still exists)
# 4 - Server not running
#
# DEPENDENCIES:
# - bash (with [[, printf, functions)
# - tmux (for has-session, list-panes)
# - ps (for process state validation)
#
# Bash strict mode:
# -e: Exit immediately if any command fails
# -u: Treat unset variables as errors
# -o pipefail: Pipe fails if any command in pipeline fails
set -euo pipefail
# Get script directory to source registry library
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/registry.sh
source "$SCRIPT_DIR/lib/registry.sh"
usage() {
cat <<'USAGE'
Usage: pane-health.sh -t target [options]
OR: pane-health.sh -s session [options]
OR: pane-health.sh [options] # auto-detect single session
Check health status of a tmux pane and report structured results.
Target Selection (priority order):
-s, --session session name (looks up socket/target in registry)
-t, --target tmux target (session:window.pane), explicit
(no flags) auto-detect if only one session in registry
Options:
-S, --socket tmux socket path (for custom sockets via -S)
--format output format: json|text (default: json)
-h, --help show this help
Exit Codes:
0 - Healthy (pane alive, process running)
1 - Dead (pane marked as dead)
2 - Missing (pane/session doesn't exist)
3 - Zombie (process exited but pane still exists)
4 - Server not running
Output Formats:
json - Structured JSON with all health information
text - Human-readable status message
Examples:
# Using session name
./pane-health.sh -s my-python
# Auto-detect single session
./pane-health.sh --format text
# Explicit socket/target (backward compatible)
./pane-health.sh -t session:0.0 -S /tmp/claude.sock
# Use in script with session registry
if ./pane-health.sh -s my-session; then
echo "Pane is ready for commands"
fi
USAGE
}
# ============================================================================
# Default Configuration
# ============================================================================
# Required parameters (must be provided by user)
target="" # tmux target pane (format: session:window.pane)
# Optional parameters
session_name="" # session name for registry lookup
socket="" # tmux socket path (empty = use default tmux socket)
output_format="json" # output format: json or text
# ============================================================================
# Parse Command-Line Arguments
# ============================================================================
while [[ $# -gt 0 ]]; do
case "$1" in
-s|--session) session_name="${2-}"; shift 2 ;; # Set session name for registry lookup
-t|--target) target="${2-}"; shift 2 ;; # Set target pane
-S|--socket) socket="${2-}"; shift 2 ;; # Set custom socket path
--format) output_format="${2-}"; shift 2 ;; # Set output format
-h|--help) usage; exit 0 ;; # Show help and exit
*) echo "Unknown option: $1" >&2; usage; exit 1 ;; # Error on unknown option
esac
done
# ============================================================================
# Session Resolution
# ============================================================================
# Resolve session name to socket/target if provided
# Priority: 1) Explicit -S/-t, 2) Session name -s, 3) Auto-detect single session
if [[ -n "$socket" && -n "$target" ]]; then
# Priority 1: Explicit socket and target provided (backward compatible)
: # Use as-is, no resolution needed
elif [[ -n "$session_name" ]]; then
# Priority 2: Session name provided, look up in registry
if ! session_data=$(registry_get_session "$session_name" 2>/dev/null); then
echo "Error: Session '$session_name' not found in registry" >&2
echo "Use 'list-sessions.sh' to see available sessions" >&2
exit 1
fi
# Extract socket and target from session data
socket=$(echo "$session_data" | jq -r '.socket')
target=$(echo "$session_data" | jq -r '.target')
# Update activity timestamp
registry_update_activity "$session_name" 2>/dev/null || true
elif [[ -z "$socket" && -z "$target" ]]; then
# Priority 3: No explicit params, try auto-detect single session
session_count=$(registry_list_sessions 2>/dev/null | jq '.sessions | length' 2>/dev/null || echo "0")
if [[ "$session_count" == "1" ]]; then
# Single session exists, auto-use it
auto_session_name=$(registry_list_sessions | jq -r '.sessions | keys[0]')
session_data=$(registry_get_session "$auto_session_name")
socket=$(echo "$session_data" | jq -r '.socket')
target=$(echo "$session_data" | jq -r '.target')
# Update activity timestamp
registry_update_activity "$auto_session_name" 2>/dev/null || true
elif [[ "$session_count" == "0" ]]; then
echo "Error: No sessions found in registry" >&2
echo "Create a session with 'create-session.sh' or specify -t and -S explicitly" >&2
exit 1
else
echo "Error: Multiple sessions found ($session_count total)" >&2
echo "Please specify session name with -s or use -t/-S explicitly" >&2
echo "Use 'list-sessions.sh' to see available sessions" >&2
exit 1
fi
fi
# ============================================================================
# Validate Required Parameters and Dependencies
# ============================================================================
# Check that required parameters were provided (after resolution)
if [[ -z "$target" ]]; then
echo "target is required" >&2
usage
exit 1
fi
# Validate output format
if [[ "$output_format" != "json" && "$output_format" != "text" ]]; then
echo "format must be 'json' or 'text'" >&2
exit 1
fi
# Check that tmux is installed and available in PATH
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux not found in PATH" >&2
exit 1
fi
# Check that ps is installed and available in PATH
if ! command -v ps >/dev/null 2>&1; then
echo "ps not found in PATH" >&2
exit 1
fi
# ============================================================================
# Build tmux Command Array
# ============================================================================
# Build base tmux command with optional socket parameter
# ${socket:+-S "$socket"} expands to "-S $socket" if socket is set, empty otherwise
tmux_cmd=(tmux ${socket:+-S "$socket"})
# ============================================================================
# Initialize Health State Variables
# ============================================================================
# Health check results (will be populated during checks)
server_running=false # Is tmux server running on the socket?
session_exists=false # Does the target session exist?
pane_exists=false # Does the target pane exist?
pane_dead=false # Is the pane marked as dead by tmux?
pid="" # Process ID running in the pane
process_running=false # Is the process actually running?
status="unknown" # Overall health status
exit_code=0 # Script exit code
# ============================================================================
# Function: output_result
# ============================================================================
# Output health check results in requested format (JSON or text)
#
# Arguments: None (uses global variables)
# Returns: None (outputs to stdout)
#
output_result() {
if [[ "$output_format" == "json" ]]; then
# --------------------------------------------------------------------------
# JSON Output Format
# --------------------------------------------------------------------------
# Structured output with all health information
# Boolean values: "true" or "false" (JSON strings)
# PID: numeric value or null if not available
#
cat <<JSON
{
"status": "$status",
"server_running": $([[ "$server_running" == true ]] && echo "true" || echo "false"),
"session_exists": $([[ "$session_exists" == true ]] && echo "true" || echo "false"),
"pane_exists": $([[ "$pane_exists" == true ]] && echo "true" || echo "false"),
"pane_dead": $([[ "$pane_dead" == true ]] && echo "true" || echo "false"),
"pid": $([[ -n "$pid" ]] && echo "$pid" || echo "null"),
"process_running": $([[ "$process_running" == true ]] && echo "true" || echo "false")
}
JSON
else
# --------------------------------------------------------------------------
# Text Output Format
# --------------------------------------------------------------------------
# Human-readable status message
#
case "$status" in
healthy)
echo "Pane $target is healthy (PID: $pid, process running)"
;;
dead)
echo "Pane $target is dead (marked as dead by tmux)"
;;
zombie)
echo "Pane $target is a zombie (pane exists but process $pid exited)"
;;
missing)
if [[ "$session_exists" == false ]]; then
echo "Session does not exist (target: $target)"
else
echo "Pane does not exist (target: $target)"
fi
;;
server_not_running)
echo "tmux server is not running on socket${socket:+: $socket}"
;;
*)
echo "Unknown status: $status"
;;
esac
fi
}
# ============================================================================
# Health Check: Step 1 - Check if tmux server is running
# ============================================================================
# Try to list sessions to verify server is running
# Redirect all output to /dev/null (we only care about exit code)
# Exit code 0 = server running, non-zero = server not running
if "${tmux_cmd[@]}" list-sessions >/dev/null 2>&1; then
server_running=true
else
# Server is not running - this is the most fundamental failure
status="server_not_running"
exit_code=4
output_result
exit "$exit_code"
fi
# ============================================================================
# Health Check: Step 2 - Extract session name from target
# ============================================================================
# Target format: session:window.pane or session:window or just session
# Extract session name (everything before first colon, or entire string if no colon)
# ${target%%:*} means: remove longest match of ":*" from the end
session_name="${target%%:*}"
# ============================================================================
# Health Check: Step 3 - Check if session exists
# ============================================================================
# Use tmux has-session to check if session exists
# -t: target session name
# Exit code 0 = session exists, non-zero = session doesn't exist
if "${tmux_cmd[@]}" has-session -t "$session_name" 2>/dev/null; then
session_exists=true
else
# Session doesn't exist - can't check pane without session
status="missing"
exit_code=2
output_result
exit "$exit_code"
fi
# ============================================================================
# Health Check: Step 4 - Check if pane exists and get pane state
# ============================================================================
# Query tmux for pane information
# list-panes -F: Format output with specific variables
# #{pane_dead}: 1 if pane is dead, 0 if alive
# #{pane_pid}: Process ID running in the pane
# -t: target (can be session, session:window, or session:window.pane)
# 2>/dev/null: Suppress errors if pane doesn't exist
#
# Note: If target is session:0.0 but pane doesn't exist, list-panes returns empty
# If target is just session, it lists all panes in session
#
if ! pane_info="$("${tmux_cmd[@]}" list-panes -F '#{pane_dead} #{pane_pid}' -t "$target" 2>/dev/null)"; then
# list-panes failed - pane doesn't exist
status="missing"
exit_code=2
output_result
exit "$exit_code"
fi
# Check if we got any output (pane exists)
if [[ -z "$pane_info" ]]; then
# No output means pane doesn't exist
status="missing"
exit_code=2
output_result
exit "$exit_code"
fi
# Pane exists - mark it and parse the info
pane_exists=true
# --------------------------------------------------------------------------
# Parse pane information
# --------------------------------------------------------------------------
# pane_info format: "0 12345" or "1 12345"
# First field: pane_dead flag (0 = alive, 1 = dead)
# Second field: pane_pid
#
# Read into variables using read command
# IFS=' ': Use space as field separator
# read -r: Don't interpret backslashes
read -r pane_dead_flag pid_value <<< "$pane_info"
# Set pane_dead boolean based on flag
if [[ "$pane_dead_flag" == "1" ]]; then
pane_dead=true
status="dead"
exit_code=1
output_result
exit "$exit_code"
fi
# Store PID value
pid="$pid_value"
# ============================================================================
# Health Check: Step 5 - Validate process is running
# ============================================================================
# Use ps to check if process with this PID is actually running
# -p: Specify process ID to check
# -o pid=: Output only PID (with no header)
# 2>/dev/null: Suppress errors if PID doesn't exist
# grep -q: Quiet mode, just check if pattern matches (exit code 0 = match)
#
if ps -p "$pid" -o pid= >/dev/null 2>&1; then
# Process is running - pane is healthy!
process_running=true
status="healthy"
exit_code=0
else
# Process is not running but pane still exists - this is a zombie
# This can happen when a process exits but tmux keeps the pane open
# (depending on remain-on-exit setting)
process_running=false
status="zombie"
exit_code=3
fi
# ============================================================================
# Output Results and Exit
# ============================================================================
output_result
exit "$exit_code"

503
tools/safe-send.sh Executable file
View File

@@ -0,0 +1,503 @@
#!/usr/bin/env bash
#
# safe-send.sh - Send keystrokes to tmux pane with retries and readiness checking
#
# PURPOSE:
# Reliably send commands to tmux panes with automatic retries, readiness checks,
# and optional prompt waiting. Prevents dropped keystrokes that can occur when
# sending to busy or not-yet-ready panes.
#
# HOW IT WORKS:
# 1. Verify pane is healthy using pane-health.sh (if available)
# 2. Attempt to send keystrokes using tmux send-keys
# 3. On failure: retry with exponential backoff (0.5s, 1s, 2s, ...)
# 4. Optionally wait for prompt pattern after sending (using wait-for-text.sh)
# 5. Return success or failure with appropriate exit code
#
# USE CASES:
# - Send commands to Python REPL with automatic retry
# - Send gdb commands and wait for prompt
# - Critical commands that must not be dropped
# - Send commands immediately after session creation
# - Integrate into automation scripts requiring reliability
#
# EXAMPLES:
# # Send Python command and wait for prompt
# ./safe-send.sh -S /tmp/my.sock -t session:0.0 -c "print('hello')" -w ">>>"
#
# # Send literal text without executing (no Enter)
# ./safe-send.sh -t myapp:0.0 -c "some text" -l
#
# # Send with custom retry settings
# ./safe-send.sh -t session:0.0 -c "ls" -r 5 -i 1.0 -T 60
#
# # Send control sequence
# ./safe-send.sh -t session:0.0 -c "C-c"
#
# EXIT CODES:
# 0 - Command sent successfully
# 1 - Failed to send after retries
# 2 - Timeout waiting for prompt
# 3 - Pane not ready
# 4 - Invalid arguments
#
# DEPENDENCIES:
# - bash (with [[, printf, sleep, bc for exponential backoff)
# - tmux (for send-keys)
# - pane-health.sh (optional, for readiness check)
# - wait-for-text.sh (optional, for prompt waiting)
#
# Bash strict mode:
# -e: Exit immediately if any command fails
# -u: Treat unset variables as errors
# -o pipefail: Pipe fails if any command in pipeline fails
set -euo pipefail
# Get script directory to source registry library
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/registry.sh
source "$SCRIPT_DIR/lib/registry.sh"
usage() {
cat <<'USAGE'
Usage: safe-send.sh -t target -c command [options]
OR: safe-send.sh -s session -c command [options]
OR: safe-send.sh -c command [options] # auto-detect single session
Send keystrokes to a tmux pane with automatic retries and readiness checking.
Target Selection (priority order):
-s, --session session name (looks up socket/target in registry)
-t, --target tmux target (session:window.pane), explicit
(no flags) auto-detect if only one session in registry
Options:
-c, --command command to send (empty = just Enter)
-S, --socket tmux socket path (for custom sockets via -S)
-L, --socket-name tmux socket name (for named sockets via -L)
-l, --literal use literal mode (send-keys -l, no Enter)
-m, --multiline use multiline mode (paste-buffer for code blocks)
-w, --wait wait for this pattern after sending
-T, --timeout timeout in seconds (default: 30)
-r, --retries max retry attempts (default: 3)
-i, --interval base retry interval in seconds (default: 0.5)
-v, --verbose verbose output for debugging
-h, --help show this help
Exit Codes:
0 - Command sent successfully
1 - Failed to send after retries
2 - Timeout waiting for prompt
3 - Pane not ready
4 - Invalid arguments
Modes:
Normal mode (default):
Sends command and presses Enter (executes in shell/REPL)
Example: safe-send.sh -c "print('hello')"
Multiline mode (-m):
Sends multiline code blocks via paste-buffer
Auto-appends blank line for REPL execution
Example: safe-send.sh -m -c "def foo():
return 42"
(Incompatible with --literal)
Literal mode (-l):
Sends exact characters without Enter (typing text)
Example: safe-send.sh -l -c "some text"
(Incompatible with --multiline)
Examples:
# Send Python command and wait for prompt
safe-send.sh -t session:0.0 -c "2+2" -w ">>>" -T 10
# Send multiline Python function
safe-send.sh -t session:0.0 -m -c "def foo():
return 42" -w ">>>"
# Send gdb command
safe-send.sh -t debug:0.0 -c "break main" -w "(gdb)"
# Send with literal mode (no Enter)
safe-send.sh -t session:0.0 -c "text" -l
# Send with custom retry settings
safe-send.sh -t session:0.0 -c "ls" -r 5 -i 1.0
# Send on named socket
safe-send.sh -L my-socket -t session:0.0 -c "echo test"
USAGE
}
# ============================================================================
# Default Configuration
# ============================================================================
# Required parameters (must be provided by user)
target="" # tmux target pane (format: session:window.pane)
command="__NOT_SET__" # command to send to the pane (sentinel value = not provided)
# Optional parameters
session_name="" # session name for registry lookup
socket="" # tmux socket path (empty = use default tmux socket)
socket_name="" # tmux socket name (for -L option)
literal_mode=false # use send-keys -l (literal mode)
multiline_mode=false # use paste-buffer for multiline code blocks
wait_pattern="" # pattern to wait for after sending (optional)
timeout=30 # timeout in seconds for prompt waiting
max_retries=3 # maximum number of send attempts
base_interval=0.5 # base interval for exponential backoff
verbose=false # enable verbose logging
# ============================================================================
# Parse Command-Line Arguments
# ============================================================================
while [[ $# -gt 0 ]]; do
case "$1" in
-s|--session) session_name="${2-}"; shift 2 ;;
-t|--target) target="${2-}"; shift 2 ;;
-c|--command) command="${2-}"; shift 2 ;;
-S|--socket) socket="${2-}"; shift 2 ;;
-L|--socket-name) socket_name="${2-}"; shift 2 ;;
-l|--literal) literal_mode=true; shift ;;
-m|--multiline) multiline_mode=true; shift ;;
-w|--wait) wait_pattern="${2-}"; shift 2 ;;
-T|--timeout) timeout="${2-}"; shift 2 ;;
-r|--retries) max_retries="${2-}"; shift 2 ;;
-i|--interval) base_interval="${2-}"; shift 2 ;;
-v|--verbose) verbose=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 4 ;;
esac
done
# ============================================================================
# Session Resolution
# ============================================================================
# Resolve session name to socket/target if provided
# Priority: 1) Explicit -S/-t, 2) Session name -s, 3) Auto-detect single session
if [[ -n "$socket" && -n "$target" ]]; then
# Priority 1: Explicit socket and target provided (backward compatible)
: # Use as-is, no resolution needed
elif [[ -n "$session_name" ]]; then
# Priority 2: Session name provided, look up in registry
if ! session_data=$(registry_get_session "$session_name" 2>/dev/null); then
echo "Error: Session '$session_name' not found in registry" >&2
echo "Use 'list-sessions.sh' to see available sessions" >&2
exit 4
fi
# Extract socket and target from session data
socket=$(echo "$session_data" | jq -r '.socket')
target=$(echo "$session_data" | jq -r '.target')
# Update activity timestamp
registry_update_activity "$session_name" 2>/dev/null || true
if [[ "$verbose" == true ]]; then
echo "Resolved session '$session_name': socket=$socket, target=$target" >&2
fi
elif [[ -z "$socket" && -z "$target" ]]; then
# Priority 3: No explicit params, try auto-detect single session
session_count=$(registry_list_sessions 2>/dev/null | jq '.sessions | length' 2>/dev/null || echo "0")
if [[ "$session_count" == "1" ]]; then
# Single session exists, auto-use it
auto_session_name=$(registry_list_sessions | jq -r '.sessions | keys[0]')
session_data=$(registry_get_session "$auto_session_name")
socket=$(echo "$session_data" | jq -r '.socket')
target=$(echo "$session_data" | jq -r '.target')
# Update activity timestamp
registry_update_activity "$auto_session_name" 2>/dev/null || true
if [[ "$verbose" == true ]]; then
echo "Auto-detected single session '$auto_session_name': socket=$socket, target=$target" >&2
fi
elif [[ "$session_count" == "0" ]]; then
echo "Error: No sessions found in registry" >&2
echo "Create a session with 'create-session.sh' or specify -t and -S explicitly" >&2
exit 4
else
echo "Error: Multiple sessions found ($session_count total)" >&2
echo "Please specify session name with -s or use -t/-S explicitly" >&2
echo "Use 'list-sessions.sh' to see available sessions" >&2
exit 4
fi
fi
# ============================================================================
# Validate Required Parameters and Dependencies
# ============================================================================
# Check that required parameters were provided (after resolution)
if [[ -z "$target" ]]; then
echo "target is required" >&2
usage
exit 4
fi
# Check that -c was provided (but empty string is allowed)
if [[ "$command" == "__NOT_SET__" ]]; then
echo "command is required (use -c \"\" to send just Enter)" >&2
usage
exit 4
fi
# Note: Empty command is allowed - it just sends Enter (useful for prompts)
# Validate that timeout is a positive number
if ! [[ "$timeout" =~ ^[0-9]+\.?[0-9]*$ ]]; then
echo "timeout must be a positive number" >&2
exit 4
fi
# Validate that max_retries is a positive integer
if ! [[ "$max_retries" =~ ^[0-9]+$ ]] || [[ "$max_retries" -lt 1 ]]; then
echo "retries must be a positive integer (>= 1)" >&2
exit 4
fi
# Validate that base_interval is a positive number
if ! [[ "$base_interval" =~ ^[0-9]+\.?[0-9]*$ ]]; then
echo "interval must be a positive number" >&2
exit 4
fi
# Check that both socket options are not specified
if [[ -n "$socket" && -n "$socket_name" ]]; then
echo "Cannot specify both -S and -L options" >&2
exit 4
fi
# Check that multiline and literal modes are not both specified
if [[ "$multiline_mode" == true && "$literal_mode" == true ]]; then
echo "Error: --multiline and --literal are mutually exclusive" >&2
exit 4
fi
# Check that tmux is installed and available in PATH
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux not found in PATH" >&2
exit 4
fi
# ============================================================================
# Helper Functions
# ============================================================================
# verbose_log: Log message if verbose mode is enabled
# Arguments: message string
# Returns: None (outputs to stderr)
verbose_log() {
if [[ "$verbose" == true ]]; then
echo "[safe-send] $*" >&2
fi
}
# ============================================================================
# Build tmux Command Array
# ============================================================================
# Build base tmux command with optional socket parameter
tmux_cmd=(tmux)
if [[ -n "$socket" ]]; then
tmux_cmd+=(-S "$socket")
verbose_log "Using socket path: $socket"
elif [[ -n "$socket_name" ]]; then
tmux_cmd+=(-L "$socket_name")
verbose_log "Using socket name: $socket_name"
fi
# ============================================================================
# Get Tool Directory for Optional Dependencies
# ============================================================================
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ============================================================================
# Pre-flight Check: Verify Pane Health
# ============================================================================
# Check if pane-health.sh is available
pane_health_tool="$SCRIPT_DIR/pane-health.sh"
if [[ -x "$pane_health_tool" ]]; then
verbose_log "Checking pane health before sending..."
# Build socket args for pane-health.sh
pane_health_args=()
if [[ -n "$socket" ]]; then
pane_health_args+=(-S "$socket")
elif [[ -n "$socket_name" ]]; then
# pane-health.sh doesn't support -L, so use default socket when -L is used
# This means health check won't work perfectly with -L, but we'll skip it gracefully
verbose_log "Warning: pane-health.sh doesn't support -L option, skipping health check"
fi
# Check pane health (exit codes: 0=healthy, 1=dead, 2=missing, 3=zombie, 4=server not running)
# Only run health check if we have socket args (not using -L)
if [[ ${#pane_health_args[@]} -gt 0 ]]; then
if ! "$pane_health_tool" "${pane_health_args[@]}" -t "$target" --format text >/dev/null 2>&1; then
health_exit=$?
echo "Error: Pane not ready (health check failed with exit code $health_exit)" >&2
verbose_log "Run '$pane_health_tool -t $target --format text' for details"
exit 3
fi
verbose_log "Pane health check passed"
fi
else
verbose_log "pane-health.sh not found, skipping health check"
fi
# ============================================================================
# Main Logic: Send Command with Retry
# ============================================================================
send_success=false
for attempt in $(seq 1 "$max_retries"); do
verbose_log "Attempt $attempt/$max_retries: Sending command to $target"
if [[ "$multiline_mode" == true ]]; then
# ============================================================
# Multiline mode: use paste-buffer
# ============================================================
verbose_log "Using multiline mode (paste-buffer)"
# Auto-append blank line if not present (for Python REPL execution)
processed_command="$command"
if [[ ! "$processed_command" =~ $'\n\n'$ ]]; then
processed_command="${processed_command}"$'\n\n'
verbose_log "Auto-appended blank line for REPL execution"
fi
# Set buffer
if ! "${tmux_cmd[@]}" set-buffer "$processed_command" 2>/dev/null; then
verbose_log "set-buffer failed on attempt $attempt"
# Continue to retry logic below (don't break early)
else
# Paste buffer to target pane
if "${tmux_cmd[@]}" paste-buffer -t "$target" 2>/dev/null; then
verbose_log "paste-buffer successful on attempt $attempt"
send_success=true
break
else
verbose_log "paste-buffer failed on attempt $attempt"
# Continue to retry logic below
fi
fi
elif [[ "$literal_mode" == true ]]; then
# ============================================================
# Literal mode: send exact characters, no Enter
# ============================================================
verbose_log "Using literal mode (-l)"
send_cmd=("${tmux_cmd[@]}" send-keys -t "$target")
send_cmd+=(-l "$command")
# Attempt to send the command
if "${send_cmd[@]}" 2>/dev/null; then
verbose_log "Send successful on attempt $attempt"
send_success=true
break
else
verbose_log "Send failed on attempt $attempt"
fi
else
# ============================================================
# Normal mode: send command and press Enter
# ============================================================
verbose_log "Using normal mode (with Enter)"
send_cmd=("${tmux_cmd[@]}" send-keys -t "$target")
send_cmd+=("$command" Enter)
# Attempt to send the command
if "${send_cmd[@]}" 2>/dev/null; then
verbose_log "Send successful on attempt $attempt"
send_success=true
break
else
verbose_log "Send failed on attempt $attempt"
fi
fi
# ============================================================
# Retry logic with exponential backoff
# ============================================================
# If this is not the last attempt, wait before retrying
if [[ $attempt -lt $max_retries ]]; then
# Calculate exponential backoff: base_interval * (2 ^ (attempt - 1))
# For base_interval=0.5: 0.5s, 1s, 2s, 4s, ...
# Using bc for floating-point arithmetic
if command -v bc >/dev/null 2>&1; then
sleep_duration=$(echo "$base_interval * (2 ^ ($attempt - 1))" | bc -l)
else
# Fallback if bc is not available: use integer arithmetic
multiplier=$((2 ** (attempt - 1)))
sleep_duration=$(echo "$base_interval * $multiplier" | awk '{print $1 * $3}')
fi
verbose_log "Waiting ${sleep_duration}s before retry..."
sleep "$sleep_duration"
fi
done
# Check if send was successful
if [[ "$send_success" == false ]]; then
echo "Error: Failed to send command after $max_retries attempts" >&2
exit 1
fi
# ============================================================================
# Optional: Wait for Prompt Pattern
# ============================================================================
# If wait pattern is specified, wait for it using wait-for-text.sh
if [[ -n "$wait_pattern" ]]; then
wait_tool="$SCRIPT_DIR/wait-for-text.sh"
if [[ -x "$wait_tool" ]]; then
verbose_log "Waiting for pattern: $wait_pattern (timeout: ${timeout}s)"
# Build socket args for wait-for-text.sh
wait_args=()
if [[ -n "$socket" ]]; then
wait_args+=(-S "$socket")
elif [[ -n "$socket_name" ]]; then
# wait-for-text.sh doesn't support -L, skip waiting with warning
echo "Warning: wait-for-text.sh doesn't support -L option, cannot wait for pattern" >&2
verbose_log "Skipping pattern wait due to -L usage"
# Exit successfully since the send was successful
exit 0
fi
# Wait for pattern (only if using -S or default socket)
if [[ ${#wait_args[@]} -ge 0 ]]; then
if "$wait_tool" "${wait_args[@]}" -t "$target" -p "$wait_pattern" -T "$timeout" >/dev/null 2>&1; then
verbose_log "Pattern found"
exit 0
else
wait_exit=$?
echo "Error: Timeout waiting for pattern '$wait_pattern'" >&2
verbose_log "wait-for-text.sh exited with code $wait_exit"
exit 2
fi
fi
else
echo "Warning: wait-for-text.sh not found, cannot wait for pattern" >&2
verbose_log "Continuing without waiting for pattern"
fi
fi
# ============================================================================
# Success
# ============================================================================
verbose_log "Command sent successfully"
exit 0

260
tools/wait-for-text.sh Executable file
View File

@@ -0,0 +1,260 @@
#!/usr/bin/env bash
#
# wait-for-text.sh - Poll a tmux pane for text pattern and exit when found
#
# PURPOSE:
# Synchronize with interactive programs running in tmux by waiting for specific
# output patterns (e.g., shell prompts, program completion messages).
#
# HOW IT WORKS:
# 1. Captures pane output at regular intervals (default: 0.5s)
# 2. Searches captured text for the specified pattern using grep
# 3. Exits successfully (0) when pattern is found
# 4. Exits with error (1) if timeout is reached
#
# USE CASES:
# - Wait for Python REPL prompt (>>>) before sending commands
# - Wait for gdb prompt before issuing breakpoint commands
# - Wait for "compilation complete" before running tests
# - Synchronize with any interactive CLI tool in tmux
#
# EXAMPLE:
# # Wait for Python prompt on custom socket
# ./wait-for-text.sh -S /tmp/my.sock -t session:0.0 -p '^>>>' -T 10
#
# # Wait for exact string "Ready" (fixed string, not regex)
# ./wait-for-text.sh -t myapp:0.0 -p 'Ready' -F -T 30
#
# DEPENDENCIES:
# - bash (with [[, printf, sleep)
# - tmux (for capture-pane)
# - grep (for pattern matching)
# - date (for timeout calculation)
#
# Bash strict mode:
# -e: Exit immediately if any command fails
# -u: Treat unset variables as errors
# -o pipefail: Pipe fails if any command in pipeline fails
set -euo pipefail
# Get script directory to source registry library
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/registry.sh
source "$SCRIPT_DIR/lib/registry.sh"
usage() {
cat <<'USAGE'
Usage: wait-for-text.sh -t target -p pattern [options]
OR: wait-for-text.sh -s session -p pattern [options]
OR: wait-for-text.sh -p pattern [options] # auto-detect single session
Poll a tmux pane for text and exit when found.
Target Selection (priority order):
-s, --session session name (looks up socket/target in registry)
-t, --target tmux target (session:window.pane), explicit
(no flags) auto-detect if only one session in registry
Options:
-p, --pattern regex pattern to look for, required
-S, --socket tmux socket path (for custom sockets via -S)
-F, --fixed treat pattern as a fixed string (grep -F)
-T, --timeout seconds to wait (integer, default: 15)
-i, --interval poll interval in seconds (default: 0.5)
-l, --lines number of history lines to inspect (integer, default: 1000)
-h, --help show this help
Examples:
# Using session name
wait-for-text.sh -s my-python -p '>>>' -T 10
# Auto-detect single session
wait-for-text.sh -p '>>>' -T 10
# Explicit socket/target (backward compatible)
wait-for-text.sh -S /tmp/my.sock -t session:0.0 -p '>>>'
USAGE
}
# ============================================================================
# Default Configuration
# ============================================================================
# Required parameters (must be provided by user)
target="" # tmux target pane (format: session:window.pane)
pattern="" # regex pattern or fixed string to search for
# Optional parameters
session_name="" # session name for registry lookup
socket="" # tmux socket path (empty = use default tmux socket)
grep_flag="-E" # grep mode: -E (extended regex, default) or -F (fixed string)
timeout=15 # seconds to wait before giving up
interval=0.5 # seconds between polling attempts
lines=1000 # number of pane history lines to capture and search
# ============================================================================
# Parse Command-Line Arguments
# ============================================================================
while [[ $# -gt 0 ]]; do
case "$1" in
-s|--session) session_name="${2-}"; shift 2 ;; # Set session name for registry lookup
-t|--target) target="${2-}"; shift 2 ;; # Set target pane
-p|--pattern) pattern="${2-}"; shift 2 ;; # Set search pattern
-S|--socket) socket="${2-}"; shift 2 ;; # Set custom socket path
-F|--fixed) grep_flag="-F"; shift ;; # Use fixed string matching
-T|--timeout) timeout="${2-}"; shift 2 ;; # Set timeout duration
-i|--interval) interval="${2-}"; shift 2 ;; # Set poll interval
-l|--lines) lines="${2-}"; shift 2 ;; # Set history depth
-h|--help) usage; exit 0 ;; # Show help and exit
*) echo "Unknown option: $1" >&2; usage; exit 1 ;; # Error on unknown option
esac
done
# ============================================================================
# Session Resolution
# ============================================================================
# Resolve session name to socket/target if provided
# Priority: 1) Explicit -S/-t, 2) Session name -s, 3) Auto-detect single session
if [[ -n "$socket" && -n "$target" ]]; then
# Priority 1: Explicit socket and target provided (backward compatible)
: # Use as-is, no resolution needed
elif [[ -n "$session_name" ]]; then
# Priority 2: Session name provided, look up in registry
if ! session_data=$(registry_get_session "$session_name" 2>/dev/null); then
echo "Error: Session '$session_name' not found in registry" >&2
echo "Use 'list-sessions.sh' to see available sessions" >&2
exit 1
fi
# Extract socket and target from session data
socket=$(echo "$session_data" | jq -r '.socket')
target=$(echo "$session_data" | jq -r '.target')
# Update activity timestamp
registry_update_activity "$session_name" 2>/dev/null || true
elif [[ -z "$socket" && -z "$target" ]]; then
# Priority 3: No explicit params, try auto-detect single session
session_count=$(registry_list_sessions 2>/dev/null | jq '.sessions | length' 2>/dev/null || echo "0")
if [[ "$session_count" == "1" ]]; then
# Single session exists, auto-use it
auto_session_name=$(registry_list_sessions | jq -r '.sessions | keys[0]')
session_data=$(registry_get_session "$auto_session_name")
socket=$(echo "$session_data" | jq -r '.socket')
target=$(echo "$session_data" | jq -r '.target')
# Update activity timestamp
registry_update_activity "$auto_session_name" 2>/dev/null || true
elif [[ "$session_count" == "0" ]]; then
echo "Error: No sessions found in registry" >&2
echo "Create a session with 'create-session.sh' or specify -t and -S explicitly" >&2
exit 1
else
echo "Error: Multiple sessions found ($session_count total)" >&2
echo "Please specify session name with -s or use -t/-S explicitly" >&2
echo "Use 'list-sessions.sh' to see available sessions" >&2
exit 1
fi
fi
# ============================================================================
# Validate Required Parameters and Dependencies
# ============================================================================
# Check that required parameters were provided (after resolution)
if [[ -z "$target" || -z "$pattern" ]]; then
echo "target and pattern are required" >&2
usage
exit 1
fi
# Validate that timeout is a positive integer (regex: one or more digits)
if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then
echo "timeout must be an integer number of seconds" >&2
exit 1
fi
# Validate that lines is a positive integer
if ! [[ "$lines" =~ ^[0-9]+$ ]]; then
echo "lines must be an integer" >&2
exit 1
fi
# Check that tmux is installed and available in PATH
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux not found in PATH" >&2
exit 1
fi
# ============================================================================
# Calculate Deadline for Timeout
# ============================================================================
# Get current time in epoch seconds (Unix timestamp)
start_epoch=$(date +%s)
# Calculate deadline: current time + timeout duration
deadline=$((start_epoch + timeout))
# ============================================================================
# Main Polling Loop
# ============================================================================
# Repeatedly capture pane output and search for pattern until found or timeout
while true; do
# --------------------------------------------------------------------------
# Step 1: Capture pane output from tmux
# --------------------------------------------------------------------------
# tmux capture-pane options:
# -p: Print to stdout (instead of saving to paste buffer)
# -J: Join wrapped lines (prevents false line breaks from terminal width)
# -t: Target pane to capture from
# -S: Start line (negative = relative to end, e.g., -1000 = last 1000 lines)
#
# ${socket:+-S "$socket"} syntax explanation:
# - If $socket is set: expands to -S "$socket"
# - If $socket is empty: expands to nothing
# This allows optional socket parameter without breaking the command
#
# Error handling:
# 2>/dev/null: Suppress error messages if pane doesn't exist
# || true: Don't fail script if capture fails (exit 0 instead)
#
pane_text="$(tmux ${socket:+-S "$socket"} capture-pane -p -J -t "$target" -S "-${lines}" 2>/dev/null || true)"
# --------------------------------------------------------------------------
# Step 2: Search captured text for pattern
# --------------------------------------------------------------------------
# Use printf to safely output text (handles special characters correctly)
# Pipe to grep to search for pattern
# $grep_flag: Either -E (regex) or -F (fixed string), set by --fixed flag
# --: Marks end of options (allows patterns starting with -)
# >/dev/null: Discard grep output (we only care about exit code)
# Exit code 0 = pattern found, 1 = not found
#
if printf '%s\n' "$pane_text" | grep $grep_flag -- "$pattern" >/dev/null 2>&1; then
# SUCCESS: Pattern found in pane output
exit 0
fi
# --------------------------------------------------------------------------
# Step 3: Check if timeout has been reached
# --------------------------------------------------------------------------
now=$(date +%s)
if (( now >= deadline )); then
# TIMEOUT: Pattern not found within specified time
echo "Timed out after ${timeout}s waiting for pattern: $pattern" >&2
echo "Last ${lines} lines from $target:" >&2
printf '%s\n' "$pane_text" >&2 # Show what was captured (for debugging)
exit 1
fi
# --------------------------------------------------------------------------
# Step 4: Wait before next poll attempt
# --------------------------------------------------------------------------
# Sleep for specified interval before checking again
# Default: 0.5 seconds (configurable via --interval)
sleep "$interval"
done