Files
2026-03-27 20:03:13 -07:00

404 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# tmux-bridge — Agent-agnostic CLI for cross-pane communication in tmux.
# Any tool that can run bash can use this to talk to other panes.
set -euo pipefail
VERSION="2.0.0"
# --- Helpers ---
die() { echo "error: $*" >&2; exit 1; }
# --- Read Guard ---
# Enforces read-before-act: agents must read a pane before typing/keys.
read_guard_path() {
local pane_id="$1"
# Sanitize: %66 → _66
echo "/tmp/tmux-bridge-read-${pane_id//%/_}"
}
mark_read() {
touch "$(read_guard_path "$1")"
}
require_read() {
local guard
guard=$(read_guard_path "$1")
if [[ ! -f "$guard" ]]; then
die "must read the pane before interacting. Run: tmux-bridge read $1"
fi
}
clear_read() {
rm -f "$(read_guard_path "$1")"
}
# Detect the correct tmux server socket.
# Priority: TMUX_BRIDGE_SOCKET env > $TMUX (if socket alive) > scan for pane owner.
detect_socket() {
# Explicit override
if [[ -n "${TMUX_BRIDGE_SOCKET:-}" ]]; then
if [[ -S "$TMUX_BRIDGE_SOCKET" ]]; then
echo "$TMUX_BRIDGE_SOCKET"
return
fi
die "TMUX_BRIDGE_SOCKET=$TMUX_BRIDGE_SOCKET is not a valid socket"
fi
# Extract from $TMUX (format: "socket_path,pid,session_index")
if [[ -n "${TMUX:-}" ]]; then
local socket="${TMUX%%,*}"
if [[ -S "$socket" ]]; then
# Verify the socket is actually reachable
if tmux -S "$socket" list-sessions &>/dev/null; then
echo "$socket"
return
fi
fi
fi
# Fallback: scan tmux server sockets for one that owns $TMUX_PANE
local pane="${TMUX_PANE:-}"
if [[ -n "$pane" ]]; then
local uid
uid=$(id -u)
# Check both /tmp and /private/tmp (macOS symlinks /tmp → /private/tmp,
# but some sandboxed environments only see one or the other)
local sock_dirs=("/tmp/tmux-${uid}" "/private/tmp/tmux-${uid}")
for sock_dir in "${sock_dirs[@]}"; do
[[ -d "$sock_dir" ]] || continue
for sock in "$sock_dir"/*; do
[[ -S "$sock" ]] || continue
if tmux -S "$sock" display-message -t "$pane" -p '#{pane_id}' &>/dev/null; then
echo "$sock"
return
fi
done
done
# Also try the default socket (no -S flag)
if tmux display-message -t "$pane" -p '#{pane_id}' &>/dev/null; then
echo "__default__"
return
fi
fi
# Last resort: try default tmux server
if tmux list-sessions &>/dev/null; then
echo "__default__"
return
fi
die "cannot find a reachable tmux server (TMUX=${TMUX:-<unset>}, TMUX_PANE=${TMUX_PANE:-<unset>})"
}
# Resolved once at startup, used by tmx() for all tmux calls.
TMUX_SOCKET=""
init_socket() {
TMUX_SOCKET=$(detect_socket)
}
# Wrapper: run tmux with the detected socket.
tmx() {
if [[ "$TMUX_SOCKET" == "__default__" || -z "$TMUX_SOCKET" ]]; then
tmux "$@"
else
tmux -S "$TMUX_SOCKET" "$@"
fi
}
usage() {
cat <<'EOF'
tmux-bridge — cross-pane communication for AI agents
Usage: tmux-bridge <command> [args...]
Commands:
list Show all panes (target, pid, command, size, label)
type <target> <text> Type text without pressing Enter
message <target> <text> Type text with auto-prepended sender info and reply target
read <target> [lines] Read last N lines from pane (default: 50)
keys <target> <key>... Send special keys (Enter, Escape, C-c, etc.)
name <target> <label> Label a pane (visible in tmux border)
resolve <label> Print pane target for a label
id Print this pane's ID ($TMUX_PANE)
doctor Diagnose tmux connectivity issues
version Print version
Target resolution:
Targets can be tmux native (session:window.pane, %N) or a label set via 'name'.
Labels are resolved automatically — e.g. 'tmux-bridge type codex "hello"' works.
Environment:
TMUX_BRIDGE_SOCKET Override the tmux server socket path (skips auto-detection)
EOF
exit 0
}
require_tmux() {
command -v tmux >/dev/null 2>&1 || die "tmux is not installed or not in PATH"
}
# Resolve a target: if it looks like a tmux target (%N, session:win.pane, pure digits),
# use it directly; otherwise treat it as a @name label and resolve.
resolve_target() {
local target="$1"
# tmux pane ID like %0, %12
if [[ "$target" =~ ^%[0-9]+$ ]]; then
echo "$target"; return
fi
# Looks like a tmux target with colon or dot (session:win.pane)
if [[ "$target" == *:* ]] || [[ "$target" == *.* ]]; then
echo "$target"; return
fi
# Pure numeric — treat as window index
if [[ "$target" =~ ^[0-9]+$ ]]; then
echo "$target"; return
fi
# Otherwise resolve as a @name label
resolve_label "$target"
}
resolve_label() {
local label="$1"
local result
result=$(tmx list-panes -a -F '#{pane_id} #{@name}' 2>/dev/null \
| awk -v lbl="$label" '$2 == lbl { print $1; exit }')
if [[ -z "$result" ]]; then
die "no pane found with label '$label'"
fi
echo "$result"
}
validate_target() {
local target="$1"
tmx display-message -t "$target" -p '#{pane_id}' >/dev/null 2>&1 \
|| die "invalid target: $target"
}
require_args() {
local need="$1" have="$2" cmd="$3"
if (( have < need )); then
die "'$cmd' requires at least $need argument(s). Run 'tmux-bridge' for usage."
fi
}
# --- Commands ---
cmd_list() {
require_tmux
printf "%-8s %-16s %-10s %-25s %-10s %s\n" "TARGET" "SESSION:WIN" "SIZE" "PROCESS" "LABEL" "CWD"
tmx list-panes -a \
-F '#{pane_id}|#{session_name}|#{window_index}|#{window_name}|#{pane_pid}|#{pane_width}x#{pane_height}|#{@name}|#{pane_current_path}' \
2>/dev/null | while IFS='|' read -r id sess widx wname pid size label cwd; do
# Get the actual running process (deepest child of pane pid)
local proc child_pid
proc=$(ps -o comm= -p "$pid" 2>/dev/null || echo "?")
# Find child process (e.g. claude/node running inside zsh) — works on macOS + Linux
child_pid=$(pgrep -P "$pid" 2>/dev/null | head -1 || true)
if [[ -n "$child_pid" ]]; then
local child_proc
child_proc=$(ps -o comm= -p "$child_pid" 2>/dev/null || true)
if [[ -n "$child_proc" ]]; then
proc="$child_proc"
fi
fi
# Shorten home dir in cwd
cwd="${cwd/#$HOME/~}"
printf "%-8s %-16s %-10s %-25s %-10s %s\n" \
"$id" "${sess}:${widx}" "$size" "$proc" "${label:--}" "$cwd"
done
}
cmd_type() {
require_args 2 $# "type"
require_tmux
local target
target=$(resolve_target "$1")
validate_target "$target"
require_read "$target"
tmx send-keys -t "$target" -l -- "$2"
clear_read "$target"
}
cmd_message() {
require_args 2 $# "message"
require_tmux
local target
target=$(resolve_target "$1")
validate_target "$target"
require_read "$target"
# Detect sender identity and location
local sender_pane="${TMUX_PANE:-}"
if [[ -z "$sender_pane" ]]; then
die "not running inside a tmux pane (\$TMUX_PANE is unset)"
fi
local sender_label
sender_label=$(tmx display-message -t "$sender_pane" -p '#{@name}' 2>/dev/null || true)
local from="${sender_label:-$sender_pane}"
local session_win
session_win=$(tmx display-message -t "$sender_pane" -p '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null || true)
local header="[tmux-bridge from:${from} pane:${sender_pane} at:${session_win} — load the smux skill to reply]"
tmx send-keys -t "$target" -l -- "${header} $2"
clear_read "$target"
}
cmd_read() {
require_tmux
require_args 1 $# "read"
local target
target=$(resolve_target "$1")
validate_target "$target"
local lines="${2:-50}"
tmx capture-pane -t "$target" -p -J -S "-${lines}"
mark_read "$target"
}
cmd_keys() {
require_args 2 $# "keys"
require_tmux
local target
target=$(resolve_target "$1")
validate_target "$target"
require_read "$target"
shift
for key in "$@"; do
tmx send-keys -t "$target" "$key"
done
clear_read "$target"
}
cmd_name() {
require_args 2 $# "name"
require_tmux
local target
target=$(resolve_target "$1")
validate_target "$target"
tmx set-option -p -t "$target" @name "$2"
}
cmd_resolve() {
require_args 1 $# "resolve"
require_tmux
resolve_label "$1"
}
cmd_id() {
if [[ -z "${TMUX_PANE:-}" ]]; then
die "not running inside a tmux pane (\$TMUX_PANE is unset)"
fi
echo "$TMUX_PANE"
}
cmd_doctor() {
require_tmux
local ok=true
echo "tmux-bridge doctor v${VERSION}"
echo "---"
# Environment
echo "TMUX_PANE: ${TMUX_PANE:-<unset>}"
echo "TMUX: ${TMUX:-<unset>}"
echo "TMUX_BRIDGE_SOCKET: ${TMUX_BRIDGE_SOCKET:-<unset>}"
# Socket from $TMUX
if [[ -n "${TMUX:-}" ]]; then
local env_socket="${TMUX%%,*}"
if [[ -S "$env_socket" ]]; then
if tmux -S "$env_socket" list-sessions &>/dev/null; then
echo "\$TMUX socket: $env_socket (reachable)"
else
echo "\$TMUX socket: $env_socket (exists but not responding)"
ok=false
fi
else
echo "\$TMUX socket: $env_socket (MISSING — stale env)"
ok=false
fi
fi
# Detected socket
echo "---"
local detected
detected=$(detect_socket 2>/dev/null || echo "__failed__")
if [[ "$detected" == "__failed__" ]]; then
echo "Detected socket: FAILED — no reachable tmux server found"
ok=false
elif [[ "$detected" == "__default__" ]]; then
echo "Detected socket: (default tmux server)"
else
echo "Detected socket: $detected"
fi
# Pane visibility
echo "---"
if [[ -n "${TMUX_PANE:-}" && "$detected" != "__failed__" ]]; then
if tmx display-message -t "$TMUX_PANE" -p '#{pane_id}' &>/dev/null; then
echo "This pane ($TMUX_PANE): visible to server"
else
echo "This pane ($TMUX_PANE): NOT visible to server"
ok=false
fi
fi
# Pane count
if [[ "$detected" != "__failed__" ]]; then
local count
count=$(tmx list-panes -a -F '#{pane_id}' 2>/dev/null | wc -l | tr -d ' ')
echo "Total panes: $count"
local labeled
labeled=$(tmx list-panes -a -F '#{@name}' 2>/dev/null | grep -cv '^$' || echo 0)
echo "Labeled panes: $labeled"
fi
echo "---"
if $ok; then
echo "Status: OK"
else
echo "Status: ISSUES DETECTED"
echo ""
echo "Likely cause: \$TMUX env var is stale (terminal was restarted)."
echo "Fix: re-source tmux env or set TMUX_BRIDGE_SOCKET explicitly."
return 1
fi
}
# --- Main ---
[[ $# -eq 0 ]] && usage
# cmd_id and cmd_doctor(detect) don't need socket pre-init for the id case
case "$1" in
id) shift; cmd_id "$@"; exit ;;
-h|--help|help) usage ;;
version) echo "tmux-bridge $VERSION"; exit ;;
esac
# All other commands need the socket
require_tmux
init_socket
case "$1" in
list) shift; cmd_list "$@" ;;
type) shift; cmd_type "$@" ;;
message|msg) shift; cmd_message "$@" ;;
read) shift; cmd_read "$@" ;;
keys) shift; cmd_keys "$@" ;;
name) shift; cmd_name "$@" ;;
resolve) shift; cmd_resolve "$@" ;;
doctor) shift; cmd_doctor "$@" ;;
*) die "unknown command: $1. Run 'tmux-bridge' for usage." ;;
esac