Running Coding Agents Remotely: SSH, tmux, and the Quest for a Seamless Experience

, Jochen

Why This Matters: Coding Agents Are Changing How I Work

Coding agents like Claude Code are quite powerful, but watching them work can be like watching paint dry—especially when you're just clicking "approve" repeatedly. Running them with --dangerously-skip-permissions speeds things up, but doing that on your main development machine? That's asking for trouble.

My solution: dedicate an old Mac mini as a sacrificial lamb for agent experiments. But this created new challenges that took me down a rabbit hole of SSH, tmux, and shell configuration.

The Requirements

For a truly productive remote agent setup, I needed:

  1. Copy and paste functionality - Essential for grabbing output, errors, or interesting code snippets
  2. SSH agent forwarding - So agents can push their work to dedicated branches for later review
  3. Multiple parallel sessions - Because why run one agent when you can run five?

What seemed simple turned into a journey through the quirks of terminal multiplexers and shell environments.

The Setup: tmux as the Foundation

tmux is perfect for this use case—it keeps sessions alive when you disconnect and lets you manage multiple agent runs. But getting it to play nicely with modern requirements took some work.

Making Copy and Paste Work

The first challenge was copy and paste. On macOS, tmux doesn't automatically integrate with the system clipboard. The solution ended up being just three lines added to my .tmux.conf:

# The important part for clipboard integration
set-option -g mode-keys vi  # has to be before bind-key -T...
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"
bind-key -T copy-mode y send-keys -X copy-pipe-and-cancel "pbcopy"

Now you can:

  • Press Ctrl-b [ to enter copy mode
  • Navigate with vi keybindings
  • Press Space to start selection
  • Press y to copy to system clipboard

It's not as smooth as native terminal selection, but it works reliably.

The SSH Agent Forwarding Challenge

This was the trickier problem. When you SSH with agent forwarding (ssh -A), your connection gets a temporary socket like /tmp/ssh-ABC/agent.123. But when you attach to an existing tmux session, it still has the old socket path from when it was created. Your SSH keys? Useless.

Understanding the Problem

Here's what happens:

  1. You SSH to your remote Mac with agent forwarding
  2. SSH creates a new agent socket
  3. You attach to an existing tmux session
  4. That session has the old SSH_AUTH_SOCK value
  5. Git operations fail because the old socket is gone

The Solution: A Two-Part Approach

Part 1: Smart Session Attachment

First, I vibe-coded together a ta (tmux attach) function for my local machine. It's ugly and long, but whatever—it works:

# ~/.config/fish/functions/ta.fish

# ta - "tmux attach" - Connect to remote tmux sessions with SSH agent forwarding
#
# This function provides an interactive way to connect to tmux sessions on remote hosts
# with proper SSH agent forwarding support. It uses fzf for session selection.
#
# How it works:
# 1. Takes a remote host as argument (can be user@host or an SSH config alias)
# 2. Connects via SSH to list available tmux sessions
# 3. Presents sessions in an interactive fzf menu with preview
# 4. Connects to the selected session with SSH agent forwarding (-A flag)
#
# SSH Agent Forwarding:
# - Uses 'ssh -A' to forward your local SSH agent to the remote host
# - This allows you to use your local SSH keys within the remote tmux session
# - Requires refresh_tmux_vars function on the remote host to update SSH_AUTH_SOCK
#   in existing shells (new shells/panes will work automatically)
#
# Special features:
# - Ctrl-N in the session list to create a new session
# - Automatically offers to create a session if none exist
# - Shows session details (windows, creation time) in the preview pane
#
# Requirements:
# - fzf installed locally for the interactive selection
# - tmux installed on the remote host
# - SSH access to the remote host
# - (Optional) refresh_tmux_vars function on remote for existing shells
#
# Usage:
#   ta myserver          # Connect using SSH config alias
#   ta user@host.com     # Connect using full hostname
#
function ta
    # Check if host argument is provided
    if test (count $argv) -eq 0
        echo "Usage: ta <user@host|ssh-alias>"
        echo "Example: ta user@example.com"
        echo "Example: ta myserver"
        return 1
    end
    
    set -l remote_host $argv[1]
    
    # Check if fzf is installed
    if not command -v fzf >/dev/null
        echo "Error: fzf is not installed. Please install it first."
        echo "  brew install fzf  # macOS"
        echo "  sudo apt install fzf  # Ubuntu/Debian"
        return 1
    end
    
    # Check SSH connection and tmux availability
    echo "Checking connection to $remote_host..."
    if not ssh -o ConnectTimeout=5 -o BatchMode=yes $remote_host "command -v tmux" >/dev/null 2>&1
        echo "Error: Cannot connect to $remote_host or tmux is not installed"
        return 1
    end
    
    # Get list of tmux sessions
    set -l sessions (ssh -A $remote_host "tmux list-sessions 2>/dev/null" | string collect)
    
    if test -z "$sessions"
        echo "No tmux sessions found on $remote_host"
        read -P "Create a new session? [Y/n] " -n 1 create_new
        echo
        
        if test "$create_new" != "n" -a "$create_new" != "N"
            read -P "Session name (leave empty for default): " session_name
            if test -n "$session_name"
                ssh -A -t $remote_host "tmux new-session -s '$session_name'"
            else
                ssh -A -t $remote_host "tmux new-session"
            end
        end
        return
    end
    
    # Use fzf to select a session
    # Pass the raw session list to fzf, let it parse naturally
    set -l selected (echo "$sessions" | \
        fzf --height=50% \
            --header="Select tmux session on $remote_host (Ctrl-N for new)" \
            --preview='echo "Session info:"; echo {}' \
            --preview-window=right:50% \
            --bind='ctrl-n:execute(echo ":::NEW_SESSION:::")+abort')
    
    # Handle fzf exit/cancellation
    if test -z "$selected"
        echo "No session selected"
        return
    end
    
    # Check if user wants a new session
    if test "$selected" = ":::NEW_SESSION:::"
        read -P "New session name: " new_session_name
        if test -n "$new_session_name"
            ssh -A -t $remote_host "tmux new-session -s '$new_session_name'"
        else
            ssh -A -t $remote_host "tmux new-session"
        end
        return
    end
    
    # Extract session name from selection (everything before the first colon)
    set -l session_name (string split -m 1 ':' $selected | head -1)
    
    # Connect to the selected session
    echo "Connecting to session: $session_name"
    ssh -A -t $remote_host "tmux attach-session -t '$session_name'"
end

This function:

  • Connects with agent forwarding enabled
  • Shows all available sessions in an interactive menu
  • Attaches to your chosen session

Part 2: Automatic Environment Updates

The real magic happens on the remote machine. I added this to my Fish shell configuration:

# ~/.config/fish/config.fish on the remote Mac

# ssh agent forwarding
if status is-interactive
    # Only run in tmux sessions
    if set -q TMUX
        # Function to update SSH agent socket from tmux environment
        function __update_ssh_auth_sock
            set -l sock (tmux showenv SSH_AUTH_SOCK 2>/dev/null | string split = | tail -1)
            if test -n "$sock" -a "$sock" != "-SSH_AUTH_SOCK" -a "$sock" != "$SSH_AUTH_SOCK"
                set -gx SSH_AUTH_SOCK $sock
            end
        end
        # Update immediately when shell starts (fixes new panes/windows)
        __update_ssh_auth_sock
        # Update before each command (fixes existing shells after reattach)
        function __ssh_agent_preexec --on-event fish_preexec
            __update_ssh_auth_sock
        end
        # Alternative: Update on each prompt (more reliable but slightly less efficient)
        # Uncomment this if the preexec version doesn't work reliably
        # function __ssh_agent_prompt --on-event fish_prompt
        #     __update_ssh_auth_sock
        # end
    end
end

And the corresponding function:

# ~/.config/fish/functions/refresh_tmux_vars.fish

function refresh_tmux_vars --on-event fish_preexec
    if set -q TMUX
        # Parse tmux showenv output and set variables
        tmux showenv -s | while read -l line
            # Match lines like: SSH_AUTH_SOCK="/tmp/ssh-xxx/agent.xxx"; export SSH_AUTH_SOCK;
            if string match -qr '^(SSH_AUTH_SOCK|DISPLAY)=(.+); export' -- $line
                # Extract variable name and value
                set -l var (string match -r '^([^=]+)' -- $line)[2]
                set -l val (string match -r '="([^"]+)"' -- $line)[2]
                
                # Set the variable if we found both
                if test -n "$var" -a -n "$val"
                    set -gx $var $val
                end
            end
        end
    end
end

This setup:

  • Checks tmux's environment for the current SSH_AUTH_SOCK
  • Updates the shell's environment to match
  • Runs automatically before every command

The key insight: work with tmux's environment preservation, not against it.

Why This Architecture?

You might wonder: why not just use Docker? Or why not run agents locally?

Docker limitations:

  • Restricted access to system resources
  • Complex volume mounting for projects
  • Performance overhead
  • Less flexibility for agent experimentation

Remote physical machine advantages:

  • Full system access for agents
  • Easy to wipe and restart if things go wrong
  • Can run multiple resource-intensive agents
  • Keeps your main machine safe from experiments

What Didn't Work: The mosh Experiment


Before settling on this SSH + tmux setup, I also tried using mosh (mobile shell) for its better connection persistence and responsiveness. Unfortunately, I hit a showstopper: connecting from an M-series Mac to an Intel-based Mac mini causes the mosh client to segfault. So much for that idea.

Update: I figured out what was wrong with mosh! It wasn't actually an M-series to Intel architecture issue. A Homebrew update had switched mosh's dependency from the older versioned `protobuf@29` to the unversioned `protobuf`, causing a library mismatch that led to segfaults.

The fix was simple:

brew uninstall --ignore-dependencies protobuf protobuf@29
brew reinstall protobuf
brew reinstall mosh

What I Might Try Next


One promising alternative I'm considering is VibeTunnel. It looks like it could provide some of the benefits I was hoping to get from mosh without the architecture compatibility issues. If anyone has experience with it for this use case, I'd love to hear about it.

Conclusion

Running coding agents on a dedicated remote machine solves the safety problem of --dangerously-skip-permissions while enabling parallel experimentation. The setup requires some initial configuration work, but the payoff is worth it: you can run multiple agents simultaneously, safely experiment with permissions, and maintain full system access for complex tasks.

Is it perfect? No. But it's a working solution that beats watching a single agent slowly modify files on your main machine.

Return to blog