Skip to content

Instantly share code, notes, and snippets.

@NathanSkene
Last active March 1, 2026 06:11
Show Gist options
  • Select an option

  • Save NathanSkene/d1ba60226201dbc998f200f83be8a2c0 to your computer and use it in GitHub Desktop.

Select an option

Save NathanSkene/d1ba60226201dbc998f200f83be8a2c0 to your computer and use it in GitHub Desktop.
Kill orphaned Claude/Codex subagent processes (tree cleanup fix)

claude-cleanup: 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 (init/launchd) and are never cleaned up. Over hours/days, hundreds accumulate — consuming RAM, causing swap thrashing, and crashing your machine.

This is a workaround until Anthropic fixes the upstream bug.

Related issues: #20369, #22554, #21378, #16508

How it works

The script finds root processes where:

  1. PPID = 1 (orphaned — parent died, reparented to init/launchd)
  2. Command contains .claude/plugins, .claude/shell-snapshots, @anthropic, @openai/codex, or codex --yolo
  3. Age > 120 seconds (won't kill active subagents during brief reparenting windows)
  4. If root matches, it kills the entire descendant process tree (prevents orphaned grandchildren from surviving)

For each PID in the tree it sends SIGTERM, waits 3s, then SIGKILL if still alive. Logs everything to ~/.claude/logs/cleanup.log.

Quick start

# Download the script
mkdir -p ~/.claude/scripts
curl -o ~/.claude/scripts/claude-cleanup.sh \
  https://gist.githubusercontent.com/NathanSkene/GIST_ID/raw/claude-cleanup.sh
chmod +x ~/.claude/scripts/claude-cleanup.sh

# Test it (dry run — shows what would be killed, kills nothing)
~/.claude/scripts/claude-cleanup.sh --dry-run

# Run it for real
~/.claude/scripts/claude-cleanup.sh

Automated setup

macOS (launchd — runs every 5 min)

# Edit the plist: replace YOUR_USERNAME with your macOS username
# Then install:
cp com.claude-code.cleanup.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.claude-code.cleanup.plist

# Verify it's running:
launchctl list | grep claude-code.cleanup

Linux (cron — runs every 5 min)

(crontab -l 2>/dev/null; echo "*/5 * * * * $HOME/.claude/scripts/claude-cleanup.sh") | crontab -

Claude Code Stop hook (runs on session exit)

Add to ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/scripts/claude-cleanup.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Recommended: Use both the scheduled job (launchd/cron) AND the Stop hook. The hook handles normal exits immediately; the scheduled job catches crashes and force-quits.

Monitoring

# Check the log
cat ~/.claude/logs/cleanup.log

# Count current orphans
ps axo pid,ppid,command | awk '$2 == 1' | grep -cE '\.claude/(plugins|shell-snapshots)|@anthropic'

# Total memory usage of claude-related processes
ps axo rss,command | grep -E '\.claude/' | awk '{sum+=$1} END {print sum/1024 "MB"}'

Uninstall

~/.claude/scripts/claude-cleanup.sh --uninstall

Configuration

Environment variable Default Description
CLAUDE_CLEANUP_MIN_AGE 120 Minimum process age (seconds) before killing
CLAUDE_CLEANUP_LOG_DIR ~/.claude/logs Log directory
#!/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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
Runs claude-cleanup.sh every 5 minutes to kill orphaned Claude Code processes.
Install:
1. Edit ProgramArguments path below to match your script location
2. Edit StandardOutPath/StandardErrorPath if desired
3. cp com.claude-code.cleanup.plist ~/Library/LaunchAgents/
4. launchctl load ~/Library/LaunchAgents/com.claude-code.cleanup.plist
Uninstall:
launchctl unload ~/Library/LaunchAgents/com.claude-code.cleanup.plist
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.claude-code.cleanup</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<!-- EDIT THIS PATH to where you saved claude-cleanup.sh -->
<string>/Users/YOUR_USERNAME/.claude/scripts/claude-cleanup.sh</string>
</array>
<key>StartInterval</key>
<integer>300</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/YOUR_USERNAME/.claude/logs/claude-cleanup-stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USERNAME/.claude/logs/claude-cleanup-stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
</dict>
</dict>
</plist>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment