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)
- 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-session
With 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 anotherUser
If 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.
The standalone script is available in two versions:
tmux_runner.sh
(Bash) - Default, faster startup, no Ruby dependency for CLI usagetmux_runner.rb
(Ruby) - Fallback for systems without Bash 4+
Both scripts have identical functionality and command-line interfaces.
# Basic usage with bash version (recommended)
./tmux_runner.sh "echo 'Hello World'"
# Or use the Ruby version
ruby tmux_runner.rb "echo 'Hello World'"
# Use a custom socket
TMUX_SOCKET_PATH=/tmp/my-socket ./tmux_runner.sh "echo 'Custom socket'"
# Use the current tmux session (no socket)
TMUX_SOCKET_PATH='' ./tmux_runner.sh "echo 'Default session'"
# Command with errors
./tmux_runner.sh "ls /nonexistent"
# Complex command
./tmux_runner.sh "ssh -J jumphost target-host hostname"
# Enable debug output
TMUX_RUNNER_DEBUG=1 ./tmux_runner.sh "your command"
The library automatically detects and uses the best available script:
- Prefers
tmux_runner.sh
(bash) if available (~6% faster) - Falls back to
tmux_runner.rb
(ruby) if bash version not found - Can be overridden by passing
script_path:
parameter
require_relative 'tmux_runner_lib'
# Create a runner instance (auto-detects bash or ruby script)
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')
# Force using a specific script version (optional)
runner_bash = TmuxRunner.new(script_path: './tmux_runner.sh')
runner_ruby = TmuxRunner.new(script_path: './tmux_runner.rb')
# 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
# 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'
). Passnil
to use the current tmux session without a socket.script_path
- Path to the standalone script (default: auto-detectstmux_runner.sh
ortmux_runner.rb
). The library prefers the bash version if available for better performance.
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 window_prefix
parameter customizes the tmux window name (default: 'tmux_runner').
Runs a command and returns just the output string. Raises an exception if the command fails.
Optional window_prefix
parameter customizes the tmux window name (default: 'tmux_runner').
Runs a command and yields the output and exit code to the block, then returns the result hash.
Optional window_prefix
parameter customizes the tmux window name (default: 'tmux_runner').
Starts a command asynchronously and immediately returns a job ID. The command runs in the background.
Optional window_prefix
parameter customizes the tmux window name (default: 'tmux_runner').
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 running jobs complete. Returns a hash of job_id => result
.
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 (.sh
or.rb
)
Two functionally identical implementations are provided:
Bash Version (tmux_runner.sh
)
- Pure bash script requiring Bash 4+
- ~6% faster than Ruby version (498ms vs 530ms average)
- No Ruby interpreter needed for CLI usage
- Passes shellcheck with zero warnings
- Default choice - Used automatically by the library
Ruby Version (tmux_runner.rb
)
- Pure Ruby requiring only stdlib
- Better for systems without Bash 4+ (older macOS, Alpine, embedded)
- Easier to extend with complex parsing logic
- Fallback option - Used when bash version unavailable
Both versions:
- Have identical command-line interfaces
- Pass the same 41-test suite with 96 assertions
- Support all features: debug mode, custom prefixes, socket options
- Handle edge cases identically (empty output, errors, Unicode, etc.)
- Creates a uniquely-named tmux window
- Sends the command with special delimiters to mark start/end
- Polls the pane until the end delimiter appears
- Captures the final pane content (after any cursor manipulation)
- Parses output between delimiters
- Extracts exit code
- Cleans up the window (if successful)
Enable debug output by setting the TMUX_RUNNER_DEBUG
environment variable:
# With bash version
TMUX_RUNNER_DEBUG=1 ./tmux_runner.sh "your command"
# With Ruby version
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
Ensure the tmux session exists and you have permissions:
ls -l /tmp/shared-session
# Should show read/write permissions
Create a session first:
tmux -S /tmp/shared-session new-session -d -s my_session
- Check if the command requires interactive input
- Ensure the command completes within 60 seconds
- 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 => result
You 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 50+ test cases covering all functionality:
# Run all tests
ruby run_tests.rb
# Run specific tests
ruby run_tests.rb --pattern concurrent
ruby run_tests.rb --pattern window_prefix
# Verbose output
ruby run_tests.rb --verbose
See TESTING.md
for detailed test documentation.
example_usage.rb
- Basic command execution, error handling, blocks, long-running commands, progress bars, complex pipes and SSHexample_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