Skip to content

Instantly share code, notes, and snippets.

@mostlyfine
Created April 13, 2026 04:19
Show Gist options
  • Select an option

  • Save mostlyfine/089c4c024ede640eeba05a81a2a3a88a to your computer and use it in GitHub Desktop.

Select an option

Save mostlyfine/089c4c024ede640eeba05a81a2a3a88a to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
set -euo pipefail
# claude-ps: Monitor Claude Code sessions running in tmux panes
WATCH_INTERVAL=1
# send subcommand constants
MAX_MESSAGE_BYTES=102400
WAIT_AFTER_PASTE="${CLAUDE_PS_WAIT_AFTER_PASTE:-0.2}"
SEND_BUFFER_NAME="claude-ps-send-$$"
SESSION_FILTER=""
# Colors and constants
BOLD='\033[1m'
GREEN='\033[32m'
YELLOW='\033[33m'
DIM='\033[2m'
RESET='\033[0m'
SEPARATOR=$(printf '%.0s─' {1..70})
usage() {
cat <<EOF
Usage:
claude-ps [-s SESSION] [-w|--watch [INTERVAL]] List Claude Code sessions
claude-ps [-s SESSION] send <id> [message] [flags] Send a message to a session
Global options:
-s SESSION Filter by tmux session name (default: all sessions)
List options:
-w, --watch [SEC] Watch mode (default: ${WATCH_INTERVAL}s interval)
-h, --help Show this help
Send options:
<id> Target pane id as a plain number (e.g. 12), from the ID
column shown by \`claude-ps\`. The '%' prefix is implicit.
[message] Message body. If omitted, the body is read from stdin.
Trailing newlines are always stripped. An empty body is
rejected.
--force Send even if the target is not idle or is this pane.
--no-limit Bypass the ${MAX_MESSAGE_BYTES}-byte size limit.
-h, --help Show this help
Examples:
claude-ps send 12 "run the failing test once more"
make test 2>&1 | claude-ps send 12
claude-ps send 12 <<'EOF_BODY'
line one
line two
EOF_BODY
Environment:
CLAUDE_PS_WAIT_AFTER_PASTE Seconds to wait between paste and Enter
(default: 0.2)
EOF
exit 0
}
check_deps() {
if ! command -v tmux &>/dev/null; then
echo "error: tmux is not installed" >&2
exit 1
fi
if ! tmux list-sessions &>/dev/null; then
echo "error: no tmux sessions found" >&2
exit 1
fi
}
validate_session() {
if [[ -n "$SESSION_FILTER" ]]; then
if ! tmux has-session -t "$SESSION_FILTER" 2>/dev/null; then
echo "error: tmux session not found: $SESSION_FILTER" >&2
exit 1
fi
fi
}
parse_args() {
watch_mode=false
while [[ $# -gt 0 ]]; do
case "$1" in
-w|--watch)
watch_mode=true
if [[ "${2:-}" =~ ^[0-9]+$ ]]; then
WATCH_INTERVAL="$2"
shift
fi
shift
;;
-h|--help)
usage
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
}
# Limitations:
# - Race window: a pane can transition to running between the idle recheck
# and paste-buffer. The double-check (below) shrinks but cannot close it.
# - Approval prompts: Claude Code's approval/confirm prompts may report the
# idle icon; sending to such a pane will paste + Enter into the prompt.
cmd_send() {
local id="" msg="" force=false no_limit=false
local seen_id=false seen_body=false
# --- parse flags and positional args ---
while [[ $# -gt 0 ]]; do
case "$1" in
--force) force=true; shift ;;
--no-limit) no_limit=true; shift ;;
-h|--help) usage ;;
--) shift; break ;;
-*)
echo "send: unknown flag: $1" >&2
return 1
;;
*)
if ! $seen_id; then
id="$1"; seen_id=true
elif ! $seen_body; then
msg="$1"; seen_body=true
else
echo "send: too many positional arguments (expected: <id> [body])" >&2
return 1
fi
shift
;;
esac
done
# handle trailing args after --
while [[ $# -gt 0 ]]; do
if ! $seen_id; then
id="$1"; seen_id=true
elif ! $seen_body; then
msg="$1"; seen_body=true
else
echo "send: too many positional arguments (expected: <id> [body])" >&2
return 1
fi
shift
done
# --- validate id ---
if ! $seen_id; then
echo "send: missing <id>" >&2
return 1
fi
if [[ ! "$id" =~ ^[0-9]+$ ]]; then
echo "send: id must be a plain number (e.g. 12), got: $id" >&2
return 1
fi
local target="%$id"
# --- obtain message body ---
if ! $seen_body; then
if [[ -t 0 ]]; then
echo "send: stdin is a tty; pipe a message or pass it as an argument" >&2
return 1
fi
if $no_limit; then
msg=$(cat)
else
# read at most MAX_MESSAGE_BYTES+1 bytes so we can detect overflow
msg=$(head -c $((MAX_MESSAGE_BYTES + 1)))
fi
fi
# --- trim trailing newlines (guarded against empty string) ---
while [[ -n "$msg" && "${msg: -1}" == $'\n' ]]; do
msg="${msg%$'\n'}"
done
# --- empty check ---
if [[ -z "$msg" ]]; then
echo "send: empty message" >&2
return 1
fi
# --- size check ---
if ! $no_limit; then
local bytes
bytes=$(printf '%s' "$msg" | wc -c)
bytes="${bytes// /}"
if (( bytes > MAX_MESSAGE_BYTES )); then
echo "send: message too large: ${bytes} bytes > ${MAX_MESSAGE_BYTES} bytes; use --no-limit to override" >&2
return 1
fi
fi
# --- initial pane existence + title fetch ---
if ! pane_exists "$target"; then
echo "send: pane $id not found" >&2
return 1
fi
local title
title=$(tmux display-message -t "$target" -p '#{pane_title}')
# --- self detection ---
if [[ -n "${TMUX_PANE:-}" && "$TMUX_PANE" == "$target" ]]; then
if ! $force; then
echo "send: refusing to send to self ($target); use --force to override" >&2
return 1
fi
fi
# --- initial idle check ---
local status
status=$(detect_claude_status "$title")
if [[ "$status" != "idle" ]] && ! $force; then
echo "send: pane $id is not idle (status: $status); use --force to override" >&2
return 1
fi
# --- load-buffer (done before recheck to minimise the race window) ---
trap 'tmux delete-buffer -b "$SEND_BUFFER_NAME" 2>/dev/null || true' EXIT
if ! printf '%s' "$msg" | tmux load-buffer -b "$SEND_BUFFER_NAME" - 2>/dev/null; then
echo "send: failed to load tmux buffer" >&2
return 1
fi
# --- pre-send idle recheck ---
local title2 status2
if ! pane_exists "$target"; then
echo "send: pane $id disappeared before send" >&2
return 1
fi
title2=$(tmux display-message -t "$target" -p '#{pane_title}')
status2=$(detect_claude_status "$title2")
if [[ "$status2" != "idle" ]] && ! $force; then
echo "send: pane $id became not idle before send (status: $status2); retry" >&2
return 1
fi
# --- paste-buffer (bracketed paste, single-use) ---
if ! tmux paste-buffer -b "$SEND_BUFFER_NAME" -p -d -t "$target" 2>/dev/null; then
echo "send: failed to paste into pane $id" >&2
return 1
fi
# paste-buffer -d deleted the buffer; no cleanup needed any more.
trap - EXIT
# --- wait for Claude Code to absorb the paste, then submit ---
sleep "$WAIT_AFTER_PASTE"
if ! tmux send-keys -t "$target" Enter 2>/dev/null; then
echo "send: pane $id disappeared during send" >&2
return 1
fi
return 0
}
is_claude_pane() {
local cmd="$1" title="$2"
[[ "$cmd" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] || [[ "$title" == *"Claude Code"* ]]
}
pane_exists() {
if [[ -n "$SESSION_FILTER" ]]; then
tmux list-panes -s -t "$SESSION_FILTER" -F '#{pane_id}' | grep -qx "$1"
else
tmux list-panes -a -F '#{pane_id}' | grep -qx "$1"
fi
}
# Detect status from pane_title icon
# ✳ (e29cb3) / ✻ (e29cbb) = idle, Braille spinner (e2a080-e2a3bf) = running
detect_claude_status() {
local title="$1"
local icon_hex
icon_hex=$(echo -n "$title" | head -c 3 | xxd -p)
if [[ "$icon_hex" == "e29cb3" || "$icon_hex" == "e29cbb" ]]; then
echo "idle"
elif [[ "$icon_hex" == e2a0* || "$icon_hex" == e2a1* || "$icon_hex" == e2a2* || "$icon_hex" == e2a3* ]]; then
echo "running"
else
echo "unknown"
fi
}
extract_task() {
local title="$1"
local task
task=$(echo "$title" | sed $'s/^[\xe2\xa0\x80-\xe2\xa3\xbf\xe2\x9c\xb3\xe2\x9c\xbb\xe2\x8f\xb5\xe2\x8f\xb6\xe2\x8f\xb7\xe2\x8f\xb8] *//')
task="${task#Claude Code}"
task="${task# - }"
if [[ ${#task} -gt 45 ]]; then
task="${task:0:42}..."
fi
echo "$task"
}
# List Claude panes as pipe-delimited lines: id|status|task|short_dir
get_list_claude_panes() {
while IFS='|' read -r pane_id cmd dir title; do
if ! is_claude_pane "$cmd" "$title"; then
continue
fi
local status short_dir task id_num
status=$(detect_claude_status "$title")
short_dir="${dir##*/}"
task=$(extract_task "$title")
id_num="${pane_id#%}"
echo "${id_num}|${status}|${task}|${short_dir}"
done < <(
if [[ -n "$SESSION_FILTER" ]]; then
tmux list-panes -s -t "$SESSION_FILTER" -F "#{pane_id}|#{pane_current_command}|#{pane_current_path}|#{pane_title}"
else
tmux list-panes -a -F "#{pane_id}|#{pane_current_command}|#{pane_current_path}|#{pane_title}"
fi
)
}
render() {
# header
printf "${BOLD} %5s %-45s %s${RESET}\n" "ID" "TASK" "DIR"
printf "%s\n" "$SEPARATOR"
# panes
local found=0
while IFS='|' read -r id_num status task short_dir; do
found=$((found + 1))
local status_icon status_color
case "$status" in
running) status_icon="●"; status_color="$GREEN" ;;
idle) status_icon="○"; status_color="$YELLOW" ;;
*) status_icon="?"; status_color="$DIM" ;;
esac
printf "${status_color}%s${RESET} ${DIM}%5s${RESET} %-45s ${DIM}%s${RESET}\n" \
"$status_icon" "$id_num" "$task" "$short_dir"
done < <(get_list_claude_panes)
if [[ $found -eq 0 ]]; then
if [[ -n "$SESSION_FILTER" ]]; then
echo "No Claude Code sessions found in tmux session: $SESSION_FILTER"
else
echo "No Claude Code sessions found in tmux."
fi
fi
# footer
printf "\n${DIM}Updated: $(date '+%H:%M:%S')${RESET}"
}
watch_loop() {
trap 'tput cnorm; tput clear; exit 0' INT TERM
tput civis
tput clear
while true; do
local buf
buf=$(render)
tput home
local label="claude-ps"
[[ -n "$SESSION_FILTER" ]] && label="claude-ps [$SESSION_FILTER]"
printf "${DIM}${label} (every ${WATCH_INTERVAL}s, Ctrl-C to stop)${RESET}\n\n"
printf '%s' "$buf"
tput ed
sleep "$WATCH_INTERVAL"
done
}
# --- Main ---
check_deps
# parse global options (must appear before subcommand)
while [[ $# -gt 0 ]]; do
case "$1" in
-s)
if [[ -z "${2:-}" || "$2" == -* ]]; then
echo "error: -s requires a session name" >&2
exit 1
fi
SESSION_FILTER="$2"
shift 2
;;
*) break ;;
esac
done
validate_session
if [[ "${1:-}" == "send" ]]; then
shift
cmd_send "$@"
exit $?
fi
parse_args "$@"
if $watch_mode; then
watch_loop
else
render
echo
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment