|
#!/bin/bash |
|
# claude-cleanup.sh — Kill orphaned Claude Code subagent processes |
|
# |
|
# Claude Code spawns child processes (bun/node) for Task tool subagents and |
|
# MCP servers. When a session ends or crashes, these get reparented to PID 1 |
|
# and are never cleaned up. This script detects and kills them. |
|
# |
|
# Related issues: |
|
# https://github.com/anthropics/claude-code/issues/20369 |
|
# https://github.com/anthropics/claude-code/issues/22554 |
|
# https://github.com/anthropics/claude-code/issues/21378 |
|
# |
|
# Safety guards: |
|
# - PPID=1 check (only targets orphaned processes) |
|
# - 120-second age minimum (prevents killing active subagents) |
|
# - PID file lock (prevents overlapping runs) |
|
# - Dry-run mode for testing |
|
# |
|
# Usage: |
|
# ./claude-cleanup.sh # Kill orphaned processes |
|
# ./claude-cleanup.sh --dry-run # Show what would be killed |
|
# ./claude-cleanup.sh --uninstall # Remove launchd job (macOS) |
|
|
|
set -euo pipefail |
|
|
|
DRY_RUN=false |
|
UNINSTALL=false |
|
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true |
|
[[ "${1:-}" == "--uninstall" ]] && UNINSTALL=true |
|
|
|
LOG_DIR="${CLAUDE_CLEANUP_LOG_DIR:-$HOME/.claude/logs}" |
|
LOG="$LOG_DIR/cleanup.log" |
|
LOCKFILE="/tmp/claude-cleanup-$(id -u).lock" |
|
MIN_AGE="${CLAUDE_CLEANUP_MIN_AGE:-120}" # seconds — never kill processes younger than this |
|
ROOT_CMD_REGEX='\.claude/(plugins|shell-snapshots)|@anthropic|@openai/codex|/usr/local/bin/codex --yolo' |
|
PROCESSED_FILE="/tmp/claude-cleanup.processed.$$" |
|
|
|
mkdir -p "$LOG_DIR" |
|
|
|
# --- Uninstall mode (macOS) --- |
|
if $UNINSTALL; then |
|
echo "Uninstalling claude-cleanup..." |
|
if [[ "$(uname)" == "Darwin" ]]; then |
|
launchctl unload ~/Library/LaunchAgents/com.claude-code.cleanup.plist 2>/dev/null || true |
|
rm -f ~/Library/LaunchAgents/com.claude-code.cleanup.plist |
|
echo "Launchd job removed." |
|
else |
|
# Linux: remove cron entry |
|
crontab -l 2>/dev/null | grep -v 'claude-cleanup' | crontab - 2>/dev/null || true |
|
echo "Cron job removed." |
|
fi |
|
echo "If using a Claude Code Stop hook, remove it manually from ~/.claude/settings.json" |
|
exit 0 |
|
fi |
|
|
|
# --- PID lock (prevent overlapping runs) --- |
|
if [[ -f "$LOCKFILE" ]]; then |
|
lock_pid=$(cat "$LOCKFILE" 2>/dev/null || echo "") |
|
if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then |
|
exit 0 # Another instance running, silently exit |
|
fi |
|
fi |
|
echo $$ > "$LOCKFILE" |
|
touch "$PROCESSED_FILE" |
|
trap 'rm -f "$LOCKFILE" "$PROCESSED_FILE"' EXIT |
|
|
|
# --- Log rotation (rotate at 1MB, keep 3 old files) --- |
|
if [[ -f "$LOG" ]]; then |
|
log_size=0 |
|
if [[ "$(uname)" == "Darwin" ]]; then |
|
log_size=$(stat -f%z "$LOG" 2>/dev/null || echo 0) |
|
else |
|
log_size=$(stat -c%s "$LOG" 2>/dev/null || echo 0) |
|
fi |
|
if [[ "$log_size" -gt 1048576 ]]; then |
|
[[ -f "$LOG.2" ]] && mv "$LOG.2" "$LOG.3" |
|
[[ -f "$LOG.1" ]] && mv "$LOG.1" "$LOG.2" |
|
[[ -f "$LOG.old" ]] && mv "$LOG.old" "$LOG.1" |
|
mv "$LOG" "$LOG.old" |
|
fi |
|
fi |
|
|
|
timestamp() { date '+%Y-%m-%d %H:%M:%S'; } |
|
|
|
# Parse etime format (MM:SS, H:MM:SS, or D-HH:MM:SS) to seconds |
|
etime_to_seconds() { |
|
local et="$1" |
|
local days=0 hours=0 mins=0 secs=0 |
|
if [[ "$et" == *-* ]]; then |
|
days="${et%%-*}" |
|
et="${et#*-}" |
|
fi |
|
IFS=: read -ra parts <<< "$et" |
|
case ${#parts[@]} in |
|
3) hours="${parts[0]}"; mins="${parts[1]}"; secs="${parts[2]}" ;; |
|
2) mins="${parts[0]}"; secs="${parts[1]}" ;; |
|
1) secs="${parts[0]}" ;; |
|
esac |
|
echo $(( days*86400 + hours*3600 + mins*60 + secs )) |
|
} |
|
|
|
already_processed() { |
|
local pid="$1" |
|
grep -qx "$pid" "$PROCESSED_FILE" 2>/dev/null |
|
} |
|
|
|
mark_processed() { |
|
local pid="$1" |
|
echo "$pid" >> "$PROCESSED_FILE" |
|
} |
|
|
|
# Recursively list descendants of a PID (one PID per line) |
|
collect_descendants() { |
|
local parent="$1" child |
|
while IFS= read -r child; do |
|
[[ -z "$child" ]] && continue |
|
echo "$child" |
|
collect_descendants "$child" |
|
done < <(pgrep -P "$parent" 2>/dev/null || true) |
|
} |
|
|
|
# Match known Claude/Codex orphan roots. Also catches orphaned pytest runners |
|
# that inherited CLAUDECODE=1 and got reparented to launchd. |
|
is_target_root() { |
|
local pid="$1" cmd="$2" |
|
|
|
echo "$cmd" | grep -qE "$ROOT_CMD_REGEX" && return 0 |
|
|
|
if echo "$cmd" | grep -qE ' -m pytest( |$)|(^|[ /])pytest( |$)'; then |
|
ps eww -p "$pid" 2>/dev/null | grep -q 'CLAUDECODE=1' && return 0 |
|
fi |
|
|
|
return 1 |
|
} |
|
|
|
# Kill a root orphan and all descendants (same user only), then log each PID. |
|
kill_tree() { |
|
local root_pid="$1" root_age="$2" root_cmd="$3" |
|
local tree all_pids user count cmd_now pid |
|
local killed_this_tree=0 |
|
|
|
tree=$( { echo "$root_pid"; collect_descendants "$root_pid"; } | awk 'NF' | sort -u ) |
|
[[ -z "$tree" ]] && return 0 |
|
|
|
# Limit to current user's processes for safety. |
|
all_pids="" |
|
while IFS= read -r pid; do |
|
[[ -z "$pid" ]] && continue |
|
user="$(ps -p "$pid" -o user= 2>/dev/null | awk '{$1=$1;print}')" |
|
[[ "$user" != "$USER" ]] && continue |
|
all_pids+="$pid"$'\n' |
|
done <<< "$tree" |
|
|
|
all_pids=$(echo "$all_pids" | awk 'NF' | sort -u) |
|
[[ -z "$all_pids" ]] && return 0 |
|
|
|
# Mark every PID in this tree so we don't process them again this run. |
|
while IFS= read -r pid; do |
|
[[ -z "$pid" ]] && continue |
|
mark_processed "$pid" |
|
done <<< "$all_pids" |
|
|
|
if $DRY_RUN; then |
|
count=$(echo "$all_pids" | wc -l | awk '{print $1}') |
|
echo "$(timestamp) [DRY-RUN] Would kill root PID $root_pid (age ${root_age}s), tree_size=$count: ${root_cmd:0:120}" | tee -a "$LOG" |
|
return 0 |
|
fi |
|
|
|
# TERM phase |
|
while IFS= read -r pid; do |
|
[[ -z "$pid" ]] && continue |
|
kill -TERM "$pid" 2>/dev/null || true |
|
done <<< "$all_pids" |
|
|
|
sleep 3 |
|
|
|
# Confirm exit, force-kill survivors, and log each. |
|
while IFS= read -r pid; do |
|
[[ -z "$pid" ]] && continue |
|
cmd_now="$(ps -p "$pid" -o command= 2>/dev/null | sed 's/^ *//')" |
|
[[ -z "$cmd_now" ]] && cmd_now="$root_cmd" |
|
if kill -0 "$pid" 2>/dev/null; then |
|
kill -9 "$pid" 2>/dev/null || true |
|
echo "$(timestamp) [FORCE-KILLED] PID $pid (root $root_pid, age ${root_age}s): ${cmd_now:0:120}" >> "$LOG" |
|
((killed_this_tree++)) || true |
|
else |
|
echo "$(timestamp) [KILLED] PID $pid (root $root_pid, age ${root_age}s): ${cmd_now:0:120}" >> "$LOG" |
|
((killed_this_tree++)) || true |
|
fi |
|
done <<< "$all_pids" |
|
|
|
killed=$((killed + killed_this_tree)) |
|
} |
|
|
|
killed=0 |
|
|
|
# Find and kill orphaned Claude Code processes |
|
# Targets root processes reparented to PID 1 (init/launchd), then kills full |
|
# descendant trees so orphaned grandchildren don't survive parent cleanup. |
|
while IFS= read -r line; do |
|
[[ -z "$line" ]] && continue |
|
|
|
pid=$(echo "$line" | awk '{print $1}') |
|
ppid=$(echo "$line" | awk '{print $2}') |
|
etime=$(echo "$line" | awk '{print $3}') |
|
cmd=$(echo "$line" | awk '{$1=$2=$3=""; print $0}' | sed 's/^ *//') |
|
|
|
# Only kill if PPID=1 (orphaned — reparented to init/launchd) |
|
[[ "$ppid" != "1" ]] && continue |
|
already_processed "$pid" && continue |
|
|
|
# Must match Claude/Codex orphan root patterns. |
|
is_target_root "$pid" "$cmd" || continue |
|
|
|
# Don't kill ourselves |
|
echo "$cmd" | grep -q 'claude-cleanup' && continue |
|
|
|
# Age guard: skip processes younger than MIN_AGE seconds |
|
age=$(etime_to_seconds "$etime") |
|
[[ "$age" -lt "$MIN_AGE" ]] && continue |
|
|
|
kill_tree "$pid" "$age" "$cmd" |
|
done < <(ps axo pid,ppid,etime,command 2>/dev/null) |
|
|
|
# Log summary |
|
if [[ $killed -gt 0 ]]; then |
|
echo "$(timestamp) Cleanup complete: $killed processes killed" >> "$LOG" |
|
elif $DRY_RUN; then |
|
echo "$(timestamp) [DRY-RUN] Cleanup complete: 0 orphans found" | tee -a "$LOG" |
|
fi |
|
|
|
# macOS notification if unusually high kill count |
|
if [[ $killed -gt 20 ]] && [[ "$(uname)" == "Darwin" ]]; then |
|
osascript -e "display notification \"Killed $killed orphaned Claude processes\" with title \"Claude Cleanup\"" 2>/dev/null || true |
|
fi |