claude-sessions: session registry manager with subcommands: - list: live tmux sessions + resumable registry entries - recover: scan profile dirs for sessions active before restart - spawn/resume: start or resume sessions - register: called by claude-tmux to auto-populate registry - gc: prune stale entries and backfill session IDs from .jsonl - (no args): interactive numbered menu claude-sessions-shutdown: called by systemd at halt/reboot; sends /exit-session to each Claude tmux session, waits up to 90s for graceful exit, force-kills stragglers. Symlinks: ~/sbin/claude-sessions, ~/sbin/claude-sessions-shutdown
144 lines
4.7 KiB
Bash
Executable File
144 lines
4.7 KiB
Bash
Executable File
#!/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"
|