#!/usr/bin/env bash # claude-sessions-shutdown — Gracefully exit all Claude Code tmux sessions. # # Called by the systemd user service before halt/reboot. # Sends /exit-session to each Claude tmux session, waits for them to close, # then force-kills any stragglers. # # Usage: # claude-sessions-shutdown [--timeout N] # graceful timeout per session (default: 90) # claude-sessions-shutdown --force # skip graceful exit, force-kill immediately set -uo pipefail REGISTRY="${HOME}/.local/share/claude-sessions/registry.json" TIMEOUT=90 FORCE=0 RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m' log() { echo -e "$(date +%T) [claude-shutdown] $*"; } warn() { echo -e "$(date +%T) [claude-shutdown] ${YELLOW}$*${NC}"; } err() { echo -e "$(date +%T) [claude-shutdown] ${RED}$*${NC}" >&2; } while [[ $# -gt 0 ]]; do case "$1" in --timeout) TIMEOUT="${2:-90}"; shift 2 ;; --force) FORCE=1; shift ;; *) err "Unknown flag: $1"; exit 1 ;; esac done # ── Collect Claude sessions ────────────────────────────────────────────────── collect_claude_sessions() { local seen=() local sessions=() # Source 1: registry entries with a live tmux session if [[ -f "$REGISTRY" ]]; then while IFS= read -r name; do [[ -n "$name" ]] || continue if tmux has-session -t "=$name" 2>/dev/null; then sessions+=("$name") seen+=("$name") fi done < <(python3 -c " import json, sys try: d = json.load(open('$REGISTRY')) for s in d.get('sessions', []): n = s.get('tmux_name', '') if n: print(n) except Exception: pass " 2>/dev/null) fi # Source 2: tmux sessions whose pane runs a node/claude child process while IFS= read -r line; do session="${line%% *}" pane_pid="${line##* }" [[ -n "$session" && -n "$pane_pid" ]] || continue # Already captured from registry? skip=0 for s in "${seen[@]+"${seen[@]}"}"; do [[ "$s" == "$session" ]] && { skip=1; break; }; done (( skip )) && continue # Check if any child of the pane PID is node (Claude Code) if pgrep -P "$pane_pid" -x node &>/dev/null 2>&1 || \ pgrep -a -P "$pane_pid" 2>/dev/null | grep -qE 'claude|\.claude'; then sessions+=("$session") fi done < <(tmux list-panes -a -F '#{session_name} #{pane_pid}' 2>/dev/null) printf '%s\n' "${sessions[@]+"${sessions[@]}"}" } # ── Main ───────────────────────────────────────────────────────────────────── if ! command -v tmux &>/dev/null; then log "tmux not found — nothing to do" exit 0 fi if ! tmux ls &>/dev/null 2>&1; then log "No tmux server running — nothing to do" exit 0 fi mapfile -t claude_sessions < <(collect_claude_sessions) if [[ ${#claude_sessions[@]} -eq 0 ]]; then log "No Claude tmux sessions found — nothing to do" exit 0 fi log "Found ${#claude_sessions[@]} Claude session(s): ${claude_sessions[*]}" if (( FORCE )); then log "Force mode: killing sessions immediately" for name in "${claude_sessions[@]}"; do tmux kill-session -t "=$name" 2>/dev/null && log "Killed: $name" || warn "Could not kill: $name" done exit 0 fi # Send /exit-session to each Claude session for name in "${claude_sessions[@]}"; do log "Sending /exit-session to: $name" # Clear any partial input, then send the command tmux send-keys -t "=$name" "" "" 2>/dev/null || true tmux send-keys -t "=$name" -l "/exit-session" 2>/dev/null || true tmux send-keys -t "=$name" Enter 2>/dev/null || true done # Wait for sessions to close log "Waiting up to ${TIMEOUT}s for sessions to exit…" deadline=$(( $(date +%s) + TIMEOUT )) remaining=("${claude_sessions[@]}") while [[ ${#remaining[@]} -gt 0 && $(date +%s) -lt $deadline ]]; do still_open=() for name in "${remaining[@]}"; do if tmux has-session -t "=$name" 2>/dev/null; then still_open+=("$name") else echo -e " ${GREEN}✓${NC} $name exited cleanly" fi done remaining=("${still_open[@]+"${still_open[@]}"}") [[ ${#remaining[@]} -gt 0 ]] && sleep 2 done # Force-kill stragglers if [[ ${#remaining[@]} -gt 0 ]]; then warn "${#remaining[@]} session(s) did not exit gracefully — force-killing" for name in "${remaining[@]}"; do tmux kill-session -t "=$name" 2>/dev/null \ && warn "Force-killed: $name" \ || err "Failed to kill: $name" done fi log "Shutdown complete"