mirror of
https://github.com/ShawnPana/smux.git
synced 2026-09-14 16:04:27 +08:00
404 lines
11 KiB
Bash
Executable File
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
|