Run commands in tmux windows and reliably capture their output, including stderr and commands with progress bars or cursor manipulation.
- ✅ Runs commands in isolated tmux windows
- ✅ Captures both stdout and stderr
- ✅ Handles commands with progress bars/cursor manipulation (captures final state)
- ✅ Works across different terminal widths with line wrapping
- ✅ Returns proper exit codes
- ✅ Can be used as standalone script or Ruby library
- ✅ Automatic window cleanup on success (configurable)
- ✅ Preserves shell variables and special characters correctly
- ✅ Supports complex SSH commands with variable expansion
- Ruby
- tmux
- A tmux session (either on a shared socket or the default session)
If you're wondering why this and not just threads or background jobs or whatever, here are some niche reasons this fills a unique niche:
- SSH Agent Forwarding: Run commands as different users while preserving the original user's ssh-agent socket. Example: connect as userX, sudo to userY, but still use userX's SSH keys for remote connections.
- Environment Inheritance: Commands run in tmux windows inherit the session's environment, including forwarded SSH agents, display settings, and authentication tokens that wouldn't survive process boundaries.
- Progress Bars and TUIs: Unlike pipes or redirects that break progress bars, this captures the final rendered state of commands with cursor manipulation (npm install, wget, apt, docker pull, etc.).
- ANSI and Terminal Features: Preserves full terminal output including colors, cursor positioning, and control sequences as they actually appeared.
- Shell Script Parallelism: Add quick-and-dirty parallel execution to bash/shell scripts without rewriting in a language with threading.
- Process-Based Concurrency: Useful when Ruby threads won't work (blocking C extensions, MRI GIL constraints, external process management).
- Persistent Windows on Failure: Failed commands leave their tmux windows open for manual inspection—you can attach to the session and see exactly what happened.
- Live Monitoring: While jobs run, you can attach to the tmux session and watch them in real-time across multiple windows.
- Sudo Context Switching: Start a process as root that needs to execute commands as the original user with that user's credentials and environment.
- User Impersonation: Run commands as service users while maintaining access to the invoking user's authentication context.
Also, because I was curious what this "vibe coding" thing is all about.
Create a tmux session on the shared socket:
tmux -S /tmp/shared-session new-session -d -s my_session
chmod 666 /tmp/shared-sessionWith newer tmux you might need to grant access to this socket, beyond filesystem permissions, if you will be using tmux-runner not as the socket owner.
tmux server-access -a anotherUserIf you're already inside a tmux session, you can use the runner without a shared socket by passing socket_path: nil to the library, or by setting TMUX_SOCKET_PATH='' for the standalone script.
# Basic usage
ruby tmux_runner.rb "echo 'Hello World'"
# Use a custom socket
TMUX_SOCKET_PATH=/tmp/my-socket ruby tmux_runner.rb "echo 'Custom socket'"
# Use the current tmux session (no socket)
TMUX_SOCKET_PATH='' ruby tmux_runner.rb "echo 'Default session'"
# Set custom timeout (in seconds)
TMUX_COMMAND_TIMEOUT=1800 ruby tmux_runner.rb "long_running_command" # 30 minutes
# No timeout (wait forever)
TMUX_COMMAND_TIMEOUT=0 ruby tmux_runner.rb "very_long_command"
# Command with errors
ruby tmux_runner.rb "ls /nonexistent"
# Complex SSH command with variables
ruby tmux_runner.rb "ssh -J jumphost target-host 'h=\$(hostname) && echo \$h'"
# SSH command with array arguments (alternative)
./tmux_runner.rb ssh -J jumphost target-host 'h=$(hostname) && echo $h'
# Enable debug output
TMUX_RUNNER_DEBUG=1 ruby tmux_runner.rb "your command"require_relative 'tmux_runner_lib'
# Create a runner instance
runner = TmuxRunner.new
# Create a runner that uses the current tmux session
runner_no_socket = TmuxRunner.new(socket_path: nil)
# Create a runner with a custom socket
runner_custom = TmuxRunner.new(socket_path: '/tmp/my-socket')
# Run a command and get results
result = runner.run("echo 'Hello'")
if result[:success]
puts "Output: #{result[:output]}"
puts "Exit code: #{result[:exit_code]}"
end
# Array arguments (avoids complex quoting for arguments with spaces)
result = runner.run("ls", "-l", "file with spaces.txt")
result = runner.run("grep", "pattern", "/path/to/file with spaces.txt")
# Run and raise on failure
begin
output = runner.run!("hostname")
puts "Hostname: #{output.strip}"
rescue => e
puts "Command failed: #{e.message}"
end
# Use a block for custom handling
runner.run_with_block("ls -l") do |output, exit_code|
lines = output.split("\n")
puts "Found #{lines.length} files"
end
# Access last result
runner.run("date")
puts "Last exit code: #{runner.last_exit_code}"
puts "Last output: #{runner.last_output}"Creates a new runner instance.
Parameters:
socket_path- Path to tmux socket (default:'/tmp/shared-session'). Passnilto use the current tmux session without a socket.script_path- Path to the standalone script (default: auto-detectstmux_runner.rb). Can be overridden to use a custom script path.
All command execution methods (run, run!, run_with_block, start) support two forms:
String form - Single command string with shell features:
runner.run("echo 'a' | cat") # Pipes work
runner.run("echo $HOME") # Variables expandArray form - Command and arguments as separate strings (no shell processing):
runner.run("echo", "a|b") # Literal: a|b (not a pipe)
runner.run("echo", "$HOME") # Literal: $HOME (not expanded)
runner.run("ls", "-l", "file.txt") # No quoting needed for spacesBenefits of array form:
- No complex quoting for arguments with spaces
- Shell metacharacters are literal (safe from injection)
- Arguments with
$,|,&,;,>,`, quotes, etc. are treated as literal strings
When to use each:
- Use string form when you need shell features (pipes, redirection, variable expansion)
- Use array form when you have literal arguments (especially with spaces or special characters)
Runs a command and returns:
:success- Boolean, true if exit code was 0:output- String, the command's stdout/stderr output:exit_code- Integer, the command's exit code:error- String or nil, error message if any:full_output- String, complete output including headers
Optional Parameters:
window_prefix- Customizes the tmux window name (default:'tmux_runner')timeout- Command timeout in seconds (default:600= 10 minutes)- Set to
0for infinite timeout (waits until command completes, no matter how long)
- Set to
Examples:
result = runner.run("echo 'hello'")
result = runner.run("ls", "-l", "file with spaces.txt")
result = runner.run(["grep", "pattern", "file.txt"])
# Long-running command with custom timeout
result = runner.run("long_process", timeout: 1800) # 30 minutes
# No timeout at all
result = runner.run("very_long_process", timeout: 0) # Wait foreverRuns a command and returns just the output string. Raises an exception if the command fails.
Optional Parameters:
window_prefix- Customizes the tmux window name (default:'tmux_runner')timeout- Command timeout in seconds (default:600= 10 minutes, or0for infinite)
Examples:
output = runner.run!("hostname")
output = runner.run!("cat", "file with spaces.txt")
output = runner.run!("long_task", timeout: 0) # No timeoutrun_with_block(command, window_prefix: 'tmux_runner', timeout: 600) { |output, exit_code| ... } → Hash
run_with_block(*args, window_prefix: 'tmux_runner', timeout: 600) { |output, exit_code| ... } → Hash
Runs a command and yields the output and exit code to the block, then returns the result hash.
Optional Parameters:
window_prefix- Customizes the tmux window name (default:'tmux_runner')timeout- Command timeout in seconds (default:600= 10 minutes, or0for infinite)
Examples:
runner.run_with_block("ls -l") { |output, code| puts output }
runner.run_with_block("grep", "pattern", "file.txt") { |output, code| puts output }
runner.run_with_block("long_task", timeout: 0) { |output, code| puts output }Starts a command asynchronously and immediately returns a job ID. The command runs in the background.
Optional Parameters:
window_prefix- Customizes the tmux window name (default:'tmux_runner')timeout- Command timeout in seconds (default:600= 10 minutes, or0for infinite)
Examples:
job_id = runner.start("sleep 5")
job_id = runner.start("grep", "pattern", "file with spaces.txt")
# Long-running SSH job with no timeout
ssh_job = runner.start("ssh remote 'long_process'", timeout: 0)Returns true if the job has completed (successfully or with error).
Returns true if the job is still running.
Blocks until the job completes and returns its result hash (same format as run()).
Returns the result hash if the job is finished, or nil if still running. Non-blocking.
Returns :running, :completed, :failed, :cancelled, or nil if job doesn't exist.
Returns all job IDs (running and completed).
Returns only the IDs of currently running jobs.
Blocks until all uncollected jobs complete. Returns a hash of job_id => result.
Important: This method returns results for ALL jobs that have been started since the last call to wait_all, including jobs that may have already finished. This ensures no job results are lost even if a job completes quickly before wait_all is called.
Calling wait_all a second time with no new jobs will return an empty hash (idempotent behavior).
Attempts to cancel a running job. Returns true if cancelled, false otherwise.
Removes a job from the internal jobs list.
last_exit_code- Exit code of the most recent commandlast_output- Output of the most recent commandsocket_path- Path to the tmux socketscript_path- Path to the script being used
The tmux runner is implemented in pure Ruby requiring only stdlib:
- Robust prompt detection using shell prompt patterns
- Handles tmux line wrapping and trailing blank lines
- Comprehensive delimiter detection to avoid false positives
- Wait-for signal mechanism for reliable synchronization
- Array-based command execution to prevent premature variable expansion
- Proper handling of shell quoting for multi-argument commands
- Passes comprehensive 145-test suite with 392 assertions
The implementation carefully handles timing issues:
- Delimiter Detection: Distinguishes between delimiter in command echo vs actual output
- Prompt Detection: Checks last 5 non-blank lines for shell prompt after command completion
- Signal Synchronization: Uses tmux wait-for signals (non-blocking) to coordinate timing
- Blank Line Handling: Properly handles tmux's fixed-height panes with trailing blanks
See test suite for detailed edge case coverage including:
- Commands without trailing newlines
- Very fast commands
- Long-running commands
- Wrapped command lines
- Multiple prompts in buffer
- Creates a uniquely-named tmux window
- Sends the command with special delimiters to mark start/end
- Polls the pane until the end delimiter appears on its own line (not in command echo)
- Waits for shell prompt to return (confirms wait-for signal completed)
- Captures the final pane content (after any cursor manipulation)
- Parses output between delimiters
- Extracts exit code from delimiter line
- Cleans up the window (if successful)
Enable debug output by setting the TMUX_RUNNER_DEBUG environment variable:
TMUX_RUNNER_DEBUG=1 ruby tmux_runner.rb "your command"Debug output includes:
- Delimiter positions in buffer
- Buffer dumps when issues occur
- Loop iteration counts
- Line capture statistics
The following environment variables can be used to configure the standalone script:
-
TMUX_SOCKET_PATH- Path to tmux socket (default:/tmp/shared-session)- Set to empty string
''to use the current tmux session without a socket
- Set to empty string
-
TMUX_WINDOW_PREFIX- Prefix for tmux window names (default:tmux_runner)- Useful for distinguishing windows from different scripts
-
TMUX_COMMAND_TIMEOUT- Command timeout in seconds (default:600= 10 minutes)- Set to
0for infinite timeout (waits until command completes) - Prevents jobs from timing out when they take longer than expected
- Set to
-
TMUX_RUNNER_DEBUG- Enable debug output (default: not set)- Set to
1to enable detailed debug information
- Set to
Examples:
# Custom socket with 30-minute timeout
TMUX_SOCKET_PATH=/tmp/my-socket TMUX_COMMAND_TIMEOUT=1800 ruby tmux_runner.rb "command"
# No timeout with custom window prefix
TMUX_COMMAND_TIMEOUT=0 TMUX_WINDOW_PREFIX=deploy ruby tmux_runner.rb "deployment"
# Debug mode with custom socket
TMUX_RUNNER_DEBUG=1 TMUX_SOCKET_PATH=/tmp/debug ruby tmux_runner.rb "test"The tmux runner preserves most special characters correctly. However, some characters require special attention:
The ! character can trigger shell history expansion in interactive shells. To safely use ! in commands:
Option 1: Use bash -c with proper quoting (recommended)
ruby tmux_runner.rb 'bash -c '"'"'msg="test!" && echo "$msg"'"'"''Option 2: Use single quotes in the variable assignment
ruby tmux_runner.rb "msg='test!' && echo \"\$msg\""Why this matters: When you run a command like msg="test!" && echo "$msg", the shell may expand ! before tmux even sees it, especially if history expansion is enabled (set +H to disable in bash).
All other common special characters work correctly with proper shell quoting:
@,#,$,%,^,&,*- Work in double quotes(,),[,],{,}- Work in double quotes|,\,;,',",<,>,?,~,`- Work with standard shell escaping
See the test suite (test/test_special_characters.rb) for working examples of each character.
Ensure the tmux session exists and you have permissions:
ls -l /tmp/shared-session
# Should show read/write permissionsCreate a session first:
tmux -S /tmp/shared-session new-session -d -s my_session- Check if the command requires interactive input
- Commands timeout after 10 minutes (600 seconds) by default
- Use
timeout: 1800for longer commands (e.g., 30 minutes) - Use
timeout: 0for no timeout (wait indefinitely)
- Use
- Enable debug mode to see what's happening
Run multiple commands in parallel:
runner = TmuxRunner.new
# Start multiple jobs
job1 = runner.start("ssh server1 hostname")
job2 = runner.start("ssh server2 hostname")
job3 = runner.start("ssh server3 hostname")
# Check status
while runner.running_jobs.any?
puts "Still running: #{runner.running_jobs.length} jobs"
sleep 1
end
# Get results
result1 = runner.result(job1)
result2 = runner.result(job2)
result3 = runner.result(job3)
# Or wait for specific job
result = runner.wait(job1) # Blocks until job1 completes
# Or wait for all
results = runner.wait_all # Hash of job_id => resultYou can customize the tmux window name prefix for better organization:
runner = TmuxRunner.new
# Use custom prefix for blocking execution
result = runner.run("hostname", window_prefix: 'myapp')
# Use custom prefix for concurrent jobs
web_job = runner.start("check_web_server", window_prefix: 'web')
db_job = runner.start("check_database", window_prefix: 'db')
cache_job = runner.start("check_cache", window_prefix: 'cache')
# Works with all run methods
output = runner.run!("command", window_prefix: 'api')
runner.run_with_block("command", window_prefix: 'worker') { |out, code| ... }Window names will be: {prefix}_{pid}_{timestamp} (e.g., web_12345_1234567890)
Comprehensive test suite with 145 test cases (392 assertions) covering all functionality:
# Run all tests (automatically starts tmux if needed)
ruby test/run_tests.rb
# Run specific test file
ruby test/test_variable_expansion_basic.rb
# Run specific test pattern
ruby test/run_tests.rb --pattern test_simple_command_success
# With verbose output
ruby test/run_tests.rb --verbose
# Or run directly (requires being inside tmux)
ruby test/test_tmux_runner.rb --name test_simple_command_successThe test runner (run_tests.rb) will automatically:
- Start a tmux session if you're not already inside one
- Create the required shared socket at
/tmp/shared-session - Set up proper permissions
- Clean up leftover test windows before running
- Validate session exists and recreate if needed
- Run all tests and display results
Test coverage includes:
- Basic Functionality: Simple commands, error handling, exit codes, multiline output
- Array Arguments: Space handling, special characters, shell metacharacters, backward compatibility (29 tests)
- Concurrent Execution: Start/wait/cancel jobs, job status tracking, parallel execution
- Race Conditions: Fast commands, slow commands, rapid sequential execution, mixed timing
- Edge Cases: Delimiter detection, prompt detection, blank lines, line wrapping, no trailing newlines
- Custom Configuration: Window prefixes, socket paths, custom commands
-
Variable Expansion (58 tests):
- Basic: Variable assignment, command substitution, quoting contexts, environment variables
- Advanced: bash -c, sh -c, special variables ($$,
$?, $ #), arrays, parameter expansion, loops, pipes, SSH-like scenarios - Edge Cases: Variables with spaces/newlines/special chars, variable isolation, empty/undefined variables
- Special Characters (22 tests): Individual tests for !, @, #, $, %, ^, &, *, (, ), [, ], {, }, |, , ;, ', ", <, >, ?, ~, `
All tests pass with 100% success rate.
example_usage.rb- Basic command execution, error handling, blocks, long-running commands, progress bars, complex pipes and SSHexample_array_args.rb- Using array arguments to handle spaces and special characters without complex quotingexample_concurrent.rb- Running multiple commands concurrently, polling job status, waiting for specific or all jobsexample_window_prefix.rb- Using custom window prefixes for better organizationexample_practical.rb- Real-world patterns: multi-server health checks, task queues with concurrency limits, timeout handling