Files
2025-11-29 18:17:58 +08:00

504 lines
18 KiB
Bash
Executable File

#!/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