Created
April 13, 2026 04:19
-
-
Save mostlyfine/089c4c024ede640eeba05a81a2a3a88a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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