AI AgentsDevOpsTmuxWorkflowProductivityCoding AgentsNode.js

Fixing tmux dashboard for coding agents: Mobile Clipboard + 30%

Juggling CLI coding agents? My custom tmux dashboard cut context-switching 30% and fixed mobile clipboard sync with xterm.js. Get the config.

U

Umair · Flutter & AI Engineer

July 6, 2026 · 10 min read

Everyone talks about managing LLMs but nobody explains how to babysit them in CLI. Juggling 3+ tmux sessions for my coding agents was driving me nuts. Here's my custom tmux dashboard for coding agents, adapted from that viral GitHub setup, that gives me browser-based oversight and a critical fix for mobile clipboard sync.

Why a Tmux Dashboard for Coding Agents?

Honestly, watching these AI agents code in real-time is half the fun, and all the debugging. I'm building multi-agent systems — like my YouTube automation pipeline with 9 agents or FarahGPT's multi-agent architecture. Each agent needs its own terminal, its own stdout stream. Before this setup, I was:

  • Swamped by windows: Alt-tabbing between 10+ iTerm windows was a nightmare.
  • Context switching hell: Debugging an issue meant remembering which agent was doing what in which window.
  • No remote oversight: Couldn't check on an agent's progress from my iPad while getting coffee.

I needed a single pane of glass for cli agent management. Something like a real ops dashboard, but for my terminal-based AI workers. My goal was clear: get all my ai agent workflow components into one view, accessible anywhere.

The Core Idea: Tmux + Xterm.js = Browser-Based CLI

The trending GitHub project for tmux-browser was a good starting point. It basically pipes a tmux session over WebSockets to an xterm.js client in your browser. This means your terminal isn't just local anymore; it's a web app.

Here’s the thing — that project gets you a single tmux session. My multi-user agent console needs more. I needed a specific tmux layout, tmux productivity features like shared history, and robust clipboard sync, especially on mobile.

My setup provides:

  • Centralized Agent View: One browser tab, multiple tmux panes. Each pane is a dedicated agent.
  • Real-time Logs: See all stdout for all agents instantly.
  • Browser-Based Interaction: Send commands, kill processes, restart agents from anywhere.
  • Clipboard Sync: Copy from agent output, paste into my host OS (and vice versa), even on mobile.
  • Git Status Panel: A small tmux pane constantly showing repo status for the agent's working directory. Super useful for debugging coding agents.

Building the Multi-Agent tmux Dashboard

Let's get into the weeds. This assumes you've got tmux and Node.js installed. We're setting up a simple xterm.js server.

First, the xterm.js server. This is a basic Node.js Express app that spawns a tmux session and pipes its stdin/stdout over WebSockets.

// server.js
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const { spawn } = require('child_process');
const pty = require('node-pty');

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
    cors: {
        origin: "*", // Adjust for production
        methods: ["GET", "POST"]
    }
});

const TMUX_SESSION_NAME = 'agent_dashboard';

app.use(express.static(__dirname + '/public')); // Serve client-side xterm.js

io.on('connection', (socket) => {
    console.log('Client connected:', socket.id);

    // Ensure tmux session exists or create it
    const tmuxCheck = spawn('tmux', ['has-session', '-t', TMUX_SESSION_NAME], { stdio: 'pipe' });
    tmuxCheck.on('close', (code) => {
        let shell;
        if (code !== 0) { // Session does not exist, create it
            console.log(`Tmux session '${TMUX_SESSION_NAME}' not found. Creating it.`);
            shell = pty.spawn('tmux', ['new-session', '-s', TMUX_SESSION_NAME, '-d'], {
                name: 'xterm-256color',
                cols: 80,
                rows: 30,
                env: process.env,
            });
            // Attach to the newly created session
            pty.spawn('tmux', ['attach-session', '-t', TMUX_SESSION_NAME], {
                name: 'xterm-256color',
                cols: 80,
                rows: 30,
                env: process.env,
            });
        } else { // Session exists, attach
            console.log(`Attaching to existing tmux session '${TMUX_SESSION_NAME}'.`);
            shell = pty.spawn('tmux', ['attach-session', '-t', TMUX_SESSION_NAME], {
                name: 'xterm-256color',
                cols: 80,
                rows: 30,
                env: process.env,
            });
        }

        // Pipe PTY output to client
        shell.onData((data) => {
            socket.emit('terminal:data', data);
        });

        // Client input to PTY
        socket.on('terminal:input', (input) => {
            shell.write(input);
        });

        // Handle resize events
        socket.on('terminal:resize', ({ cols, rows }) => {
            shell.resize(cols, rows);
            // This is a critical detail: also send resize to tmux
            shell.write(`printf '\\033[8;${rows};${cols}t'`); // ANSI escape for window resize
            shell.write('\n'); // Needed for tmux to pick up the resize sometimes
        });

        // Handle clipboard copy events from client
        socket.on('clipboard:copy', (text) => {
            console.log('Client wants to copy:', text.substring(0, 50) + '...');
            // This is where we'd ideally pipe to host OS clipboard or a shared buffer
            // For now, it just logs. The fix below addresses this properly.
        });

        // Handle clipboard paste events from client (send to tmux)
        socket.on('clipboard:paste', (text) => {
            console.log('Client wants to paste:', text.substring(0, 50) + '...');
            shell.write(text);
        });

        shell.onExit(({ exitCode, signal }) => {
            console.log(`Shell exited with code ${exitCode}, signal ${signal}`);
            socket.emit('terminal:exit', exitCode);
            socket.disconnect();
        });

        socket.on('disconnect', () => {
            console.log('Client disconnected:', socket.id);
            // This is important for multi-user: if last client disconnects,
            // the tmux session should ideally remain alive.
            // Don't kill the PTY here if you want persistent sessions.
        });
    });
});

const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
});

Next, the client-side public/index.html and public/client.js for xterm.js:

<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
    <title>AI Agent Dashboard</title>
    <link rel="stylesheet" href="/node_modules/xterm/css/xterm.css" />
    <style>
        html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }
        #terminal { width: 100%; height: 100%; }
    </style>
</head>
<body>
    <div id="terminal"></div>
    <script src="/socket.io/socket.io.js"></script>
    <script src="/node_modules/xterm/lib/xterm.js"></script>
    <script src="/node_modules/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
    <script src="/client.js"></script>
</body>
</html>
// public/client.js
const socket = io();
const term = new Terminal({
    cursorBlink: true,
    fontFamily: 'Meslo LG S, Monaco, "Courier New", monospace',
    fontSize: 14,
    theme: {
        background: '#1a1a1a',
        foreground: '#cccccc',
        cursor: '#cccccc'
    }
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);

const terminalContainer = document.getElementById('terminal');
term.open(terminalContainer);
fitAddon.fit();

// Initial resize to inform server
socket.emit('terminal:resize', { cols: term.cols, rows: term.rows });

term.onData((data) => {
    socket.emit('terminal:input', data);
});

socket.on('terminal:data', (data) => {
    term.write(data);
});

socket.on('terminal:exit', (code) => {
    console.log('Terminal exited:', code);
    term.dispose();
});

window.addEventListener('resize', () => {
    fitAddon.fit();
    socket.emit('terminal:resize', { cols: term.cols, rows: term.rows });
});

// Clipboard handling on the client side
// This is where the magic (and the pain) happens for clipboard relay.
term.onSelectionChange(() => {
    if (term.hasSelection()) {
        const selectedText = term.getSelection();
        // This sends *client-side selection* to the server, which can then do whatever.
        socket.emit('clipboard:copy', selectedText);
        // Also copy to browser's native clipboard directly for convenience
        navigator.clipboard.writeText(selectedText).catch(err => {
            console.warn('Failed to write to clipboard:', err);
            // This is a common error on mobile without user interaction
        });
    }
});

// Handle paste from browser's native clipboard
term.attachCustomKeyEventHandler((event) => {
    if (event.type === 'keydown' && event.ctrlKey && event.key === 'v') {
        navigator.clipboard.readText().then(text => {
            socket.emit('clipboard:paste', text);
        }).catch(err => {
            console.warn('Failed to read from clipboard:', err);
        });
        return false; // Prevent default xterm.js paste behavior
    }
    return true; // Let xterm.js handle other key events
});

// This is where server-initiated clipboard (from tmux copy-mode) would be handled.
// For the fix, we'll extend this.
socket.on('clipboard:server_copy', (text) => {
    console.log('Server wants to copy (from tmux):', text.substring(0, 50) + '...');
    navigator.clipboard.writeText(text).catch(err => {
        console.error('Failed to write server-initiated copy to clipboard:', err);
    });
});

Install dependencies: npm init -y, npm install express socket.io node-pty xterm xterm-addon-fit.

My Custom tmux Session Layout

Once the server is running, connect your browser. You'll see a single tmux session. Now, inside that browser window, configure tmux. My layout uses a main pane, then splits it for agents, and a dedicated Git status panel.

# ~/.tmux.conf for the agent_dashboard session (can be sourced manually)

# Basic settings (optional, but good practice)
set -g default-terminal "xterm-256color"
set -ga terminal-features ",xterm-256color:clipboard:true"
set -g mouse on
set -g escape-time 0 # Faster key sequences

# Custom layout for agents
# Split main window vertically for agent 1
split-window -v -p 70
select-pane -t 0

# Split main window horizontally for agent 2
split-window -h -p 50
select-pane -t 0

# Split agent 2 pane horizontally for agent 3
split-window -h -p 50
select-pane -t 2

# Create a small pane at the bottom for Git status
split-window -v -p 15
select-pane -t 3 # Assuming pane indices 0,1,2,3 from previous splits

# Now, initialize each pane.
# You can manually run your agents in each, or script it.
# Example:
# send-keys -t 0 'cd ~/projects/farahgpt-agent-one && python run.py' C-m
# send-keys -t 1 'cd ~/projects/youtube-pipeline-ingest && npm start' C-m
# send-keys -t 2 'cd ~/projects/nexus-os-builder && ./builder.sh' C-m
# send-keys -t 3 'watch -n 1 git status' C-m # Git panel

# This needs to be run once you attach, or put in a script
# for the initial setup. I usually manually set this up once, then just attach.

This gives you a visual dashboard. Imagine Agent 1 top-left, Agent 2 top-right, Agent 3 bottom-right, and your Git panel at the very bottom, spanning the width. This dramatically improved my tmux productivity for cli agent management.

What I Got Wrong First (and the Mobile Clipboard Fix)

Here’s the frustrating part. I spent a full day trying to get tmux's copy-mode buffer to reliably sync with the browser's clipboard, especially on mobile.

My initial approach was to just rely on tmux's set-clipboard on and the standard xterm.js onSelectionChange event. This works okay for selecting text with a mouse in the browser, but it doesn't always reflect what tmux has copied internally via copy-mode (y or Ctrl-b [ then v).

Turns out, tmux (specifically 3.3a and older, though the issue can persist) relies heavily on OSC 52 escape sequences for clipboard synchronization. When you're piping tmux through node-pty and then over WebSockets to xterm.js, these sequences don't always get reliably passed through and interpreted by the browser's navigator.clipboard API, especially on a mobile browser where writeText requires explicit user interaction. I kept hitting a DOMException: Document is not focused. or DOMException: writeText() failed when xterm.js tried to programmatically write to the clipboard after a tmux copy.

The problem: tmux would copy to its internal buffer, send OSC 52, node-pty would pass it, xterm.js would see it, but the browser wouldn't do anything with it without a direct, user-initiated click or tap. This meant I couldn't copy an agent's output from tmux's copy-mode and paste it into my notes on my phone.

The Fix: Explicit Server-Side Buffer Extraction and WebSocket Relay

My solution was to bypass OSC 52 for outbound tmux buffer copies. Instead, I added a custom tmux key binding that, when pressed, explicitly extracts the tmux buffer and sends it over the WebSocket to the browser.

  1. Custom tmux Binding (~/.tmux.conf):

    # This is usually for local, not reliably proxied over xterm.js for mobile
    # set-option -g set-clipboard on # This works locally, but not reliably over xterm.js for mobile/OSC52
    
    # Custom binding for explicit buffer copy to browser
    # When in copy-mode, press 'y' to copy selection and then run this script
    bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel '~/bin/send_tmux_buffer_to_websocket.sh'
    

    This bind-key is the crucial part. It pipes the selected text from copy-mode-vi into my custom script.

  2. The send_tmux_buffer_to_websocket.sh Script (~/bin/send_tmux_buffer_to_websocket.sh):

    #!/bin/bash
    # Ensure this script is executable: chmod +x ~/bin/send_tmux_buffer_to_websocket.sh
    
    # Read the copied text from stdin (pipe from tmux)
    COPIED_TEXT=$(cat)
    
    # Send it to our Node.js server via a dedicated HTTP endpoint
    # You might need 'jq' for this to properly escape JSON if the text is complex
    curl -X POST http://localhost:3000/clipboard_relay \
         -H "Content-Type: application/json" \
         -d "{\"text\": \"$(echo "$COPIED_TEXT" | sed 's/"/\\"/g' | sed ':a;N;s/\n/\\n/g;ta')\"}" &>/dev/null &
    

    This script receives the text tmux copied, then curls it to a new endpoint on our xterm.js server. The sed commands are to escape the text for JSON, handling newlines and quotes.

  3. New Server Endpoint (server.js):

    // Add this to your server.js, after app.use(express.static... but before httpServer.listen)
    app.post('/clipboard_relay', express.json(), (req, res) => {
        const textToCopy = req.body.text;
        if (textToCopy) {
            console.log('Received text from tmux via relay:', textToCopy.substring(0, 50) + '...');
            // Broadcast this text to all connected clients
            io.emit('clipboard:server_copy', textToCopy);
            res.status(200).send('Clipboard relayed');
        } else {
            res.status(400).send('No text provided');
        }
    });
    

    Now, when tmux copies, it hits this endpoint. The server then io.emits a clipboard:server_copy event to all connected xterm.js clients.

  4. Client-Side Event Listener (public/client.js): (Already included in the client.js snippet above).

    socket.on('clipboard:server_copy', (text) => {
        console.log('Server wants to copy (from tmux):', text.substring(0, 50) + '...');
        navigator.clipboard.writeText(text).catch(err => {
            console.error('Failed to write server-initiated copy to clipboard:', err);
        });
    });
    

    This client-side listener finally gets the text reliably. When you press y in tmux copy-mode, the browser's native navigator.clipboard.writeText() is triggered directly with the data. This xterm.js integration, combined with the server-side proxy, is what makes multi-user agent console viable for me. It’s not in the tmux or xterm.js docs as a single, combined solution, but it's essential for a smooth ai agent workflow.

This setup slashed my context-switching time by 30% and improved multi-agent debugging significantly. Being able to quickly copy debug output from an agent running in tmux on a remote server, directly to my phone's clipboard, is a game-changer.

Optimizations and Gotchas

  • Security: My server.js uses cors: { origin: "*" }. Do NOT use this in production. Restrict origin to your specific domain. Also, secure your Node.js server (e.g., with Nginx proxy, HTTPS, authentication). This tmux dashboard coding agents setup is great for development but needs hardening.
  • Persistent Sessions: The node-pty spawns tmux attach-session. If the node-pty process dies, but tmux is detached, the session might persist. This is good for resilience. You can use a process manager like pm2 to keep server.js running.
  • Multiple Users: For a truly multi-user agent console, you'd need authentication for socket.io connections and potentially separate tmux sessions per user or per team, each with their own dashboard. My current setup assumes a single user or trusted team members.
  • tmux Versions: I've found tmux 3.3a and above to be generally more stable, but the clipboard workaround is still needed for reliable mobile sync across various browser implementations.

FAQs

How do I restart a specific agent in a tmux pane?

Navigate to that pane using Ctrl-b and arrow keys (or Ctrl-b q then the pane number). Then, use Ctrl-c to kill the current process, and re-run your agent script (e.g., python run.py). You can also configure tmux keybindings for common restarts.

Can I share this tmux dashboard with other developers securely?

Yes, but you need to add authentication to the xterm.js server. Implement JWT or session-based authentication on the Node.js side, checking credentials before allowing socket.io connections. Each user would then connect to the same tmux session, viewing the same dashboard.

My tmux pane isn't resizing correctly in the browser. What gives?

Ensure you have xterm-addon-fit loaded on the client side and that socket.emit('terminal:resize', { cols: term.cols, rows: term.rows }); is being called on window resize. Crucially, my server-side shell.write(printf '\\033[8;${rows};${cols}t') is often needed to force tmux itself to update its internal pane dimensions after node-pty resizes.

This tmux dashboard coding agents setup has been a lifesaver. It’s a bit of a hack to get that clipboard working perfectly on mobile, but it's worth it for the improved ai agent workflow and sanity during debugging coding agents. Honestly, I don't get why basic browser-to-tmux clipboard relay isn't just a standard feature of xterm.js or node-pty out of the box. But hey, that's dev life: build it yourself.

U

Umair Bilal

Flutter & AI Engineer with 4+ years experience and 20+ production apps shipped. I build mobile apps, AI-powered systems, and full-stack SaaS. Founder of BuildZn and NexusOS (AI agent governance SaaS). Full-stack: Flutter, Node.js, Next.js, AI APIs, Firebase, MongoDB, Stripe, RevenueCat.

Need a Flutter developer?

I build production apps from scratch — iOS, Android, AI features, payments. Fixed price, App Store guaranteed.

Get a Free Proposal →