Skip to content

Instantly share code, notes, and snippets.

@steven4354
Last active July 12, 2026 23:30
Show Gist options
  • Select an option

  • Save steven4354/5371ec7176f54c8d312e354b7d5b098a to your computer and use it in GitHub Desktop.

Select an option

Save steven4354/5371ec7176f54c8d312e354b7d5b098a to your computer and use it in GitHub Desktop.
claude-idle-reaper: free the RAM held by idle Claude Code sessions — kills safely-idle TUIs, prints an AI recap + resume command into the tab

claude-idle-reaper

Moved: now maintained at https://github.com/steven4354/claude-idle-reaper — with a one-line installer:

curl -fsSL https://raw.githubusercontent.com/steven4354/claude-idle-reaper/main/install.sh | bash

Free the RAM held by idle Claude Code sessions — without losing them.

The problem

Every interactive Claude Code session holds 200–600+ MB of RAM forever, even when it's been sitting idle in a terminal tab for days. If you work with many tabs (Warp, iTerm, tmux), this quietly eats 10–15 GB and fills your swap. There is no built-in idle-timeout, session-suspend, or memory-limit setting, and the NODE_OPTIONS --max-old-space-size workaround doesn't apply to the native binary install. (See anthropics/claude-code issues #25545, #18859, #4953.)

The key insight — borrowed from how OpenAI's Codex CLI stays lean — is that an idle session has no reason to exist as a process. Claude Code persists every transcript to disk continuously, and claude --resume <id> restores a session losslessly. So: kill idle sessions, keep the resume path warm.

What it does

A ~130-line bash script, run hourly by launchd, that:

  1. Finds claude TUI processes that are safely idle — all of:
    • no keystroke in their tab for IDLE_HOURS (default 4h, via tty atime)
    • CPU at 0
    • no new transcript message for QUIET_MINS (default 2h) — this protects autonomous agents/loops that work without keyboard input
    • confidently mapped to their session transcript (unmappable → never killed)
  2. SIGTERMs them (graceful; Claude Code exits cleanly)
  3. Summarizes the session transcript with claude --model haiku -p
  4. Prints the summary into the dead session's own tab, so instead of a blank screen you see:
────────────────────────────────────────────
💤 Idle Claude session closed to free 334MB (no input for 21h)

Topic: Diagnosing task system failures via telemetry
- Queried settlement telemetry and prod DB; found 22 tasks across 5 failure classes
- Judge-starvation P0 still live; no fix PR on main yet
- Applied debugging frameworks to map cascading mitigations as root causes

Pending: Write P0 fix PR, merge circuit breaker, add staleness alert.

▶ Pick up where you left off:  claude --resume 10da943d-4991-4ec7-a4a1-46287e89d3a1
────────────────────────────────────────────

(Without this, the tab goes blank on exit — Claude Code renders on the terminal's alternate screen, so the conversation vanishes with the process.)

Install (macOS)

mkdir -p ~/.claude/scripts
cp reap-idle-claude.sh ~/.claude/scripts/
chmod +x ~/.claude/scripts/reap-idle-claude.sh

sed "s|/Users/USERNAME|$HOME|g" com.user.claude-idle-reaper.plist \
  > ~/Library/LaunchAgents/com.user.claude-idle-reaper.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.claude-idle-reaper.plist

Try it first without killing anything:

DRY_RUN=1 bash ~/.claude/scripts/reap-idle-claude.sh

Reap one specific session on demand (overriding the idle guards):

ONLY_PID=12345 IDLE_HOURS=0 QUIET_MINS=0 bash ~/.claude/scripts/reap-idle-claude.sh

Uninstall:

launchctl bootout gui/$(id -u)/com.user.claude-idle-reaper
rm ~/Library/LaunchAgents/com.user.claude-idle-reaper.plist

Log: ~/.claude/scripts/idle-reaper.log

Implementation notes (the non-obvious parts)

  • PID → session mapping without lsof. Claude Code doesn't keep its transcript file open. Resumed sessions carry the session UUID in their command line (claude --resume <uuid>); fresh sessions are matched by transcript file birth time (stat -f %B) falling just after process start. Near-simultaneous launches are disambiguated by claiming each transcript at most once; anything ambiguous is skipped, not killed.
  • Transcript mtime lies. Claude Code bulk-touches transcript mtimes (dozens of files at the same second, e.g. during startup grooming), so "recently modified" ≠ "recently active". The script reads the last embedded "timestamp": from the file tail instead.
  • Idle = tty atime. Last keyboard input in the tab is stat -f %a /dev/ttysNNN — the same signal w uses.
  • The summary is printed after the process dies, so it lands on the normal screen buffer (the alternate screen is gone by then) and persists in the tab.

Caveats

  • macOS only (BSD stat/date/launchd). Linux port would need stat -c, GNU date -d, and a systemd user timer.
  • A session that's mid-turn but blocked on a very long API call shows 0% CPU; the 4h keyboard-idle + 2h transcript-quiet thresholds are what protect it. Don't crank both to near zero in the plist.
  • The Haiku summary costs one small API call per reaped session.
<?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">
<plist version="1.0">
<dict>
<key>Label</key><string>com.user.claude-idle-reaper</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/USERNAME/.claude/scripts/reap-idle-claude.sh</string>
</array>
<key>StartInterval</key><integer>3600</integer>
<key>StandardOutPath</key><string>/Users/USERNAME/.claude/scripts/idle-reaper.log</string>
<key>StandardErrorPath</key><string>/Users/USERNAME/.claude/scripts/idle-reaper.log</string>
</dict>
</plist>
#!/bin/bash
# reap-idle-claude.sh — free RAM by exiting idle Claude Code TUI sessions (macOS).
#
# Idle Claude Code sessions hold 200-600+ MB each, indefinitely. Sessions are
# losslessly resumable (`claude --resume <id>`), so an idle one has no reason
# to stay resident. Before a session is reaped, its transcript is summarized
# (claude -p) and the summary + exact resume command are printed into the
# session's own terminal tab — so the tab is never left blank.
#
# Guards: only kills a `claude` TUI whose tab has had no keystroke for
# IDLE_HOURS, whose CPU is 0, and whose transcript has had no new message for
# QUIET_MINS (protects autonomous loops that run without keyboard input).
# Sessions that can't be mapped to a transcript are skipped, never killed.
#
# DRY_RUN=1 — list what would be reaped, touch nothing
# ONLY_PID=n — restrict to one process (testing / reap-on-demand)
# MAX_KILLS=n — cap reaps per run
IDLE_HOURS=${IDLE_HOURS:-4}
QUIET_MINS=${QUIET_MINS:-120}
MAX_KILLS=${MAX_KILLS:-100}
PROJECTS="$HOME/.claude/projects"
CLAUDE_BIN=${CLAUDE_BIN:-$(command -v claude || echo "$HOME/.local/bin/claude")}
now=$(date +%s)
killed=0
log() { printf '%s %s\n' "$(date '+%F %T')" "$*"; }
with_timeout() { # seconds cmd...
local t=$1; shift
"$@" & local p=$!
# watchdog must not inherit our stdout: a $(capture) waits for the pipe to
# close, so an inherited fd would block the caller until the sleep finishes
( sleep "$t"; kill -9 "$p" 2>/dev/null ) >/dev/null 2>&1 & local w=$!
wait "$p" 2>/dev/null; local rc=$?
kill "$w" 2>/dev/null
return $rc
}
claimed=" "
session_file() { # pid args -> transcript path (empty if unmappable)
local uuid start best bestd f b d
uuid=$(grep -oE '\-\-?r(esume)? [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' <<<"$2" | awk '{print $2}')
if [ -n "$uuid" ]; then
ls "$PROJECTS"/*/"$uuid".jsonl 2>/dev/null | head -1
return
fi
start=$(date -j -f '%a %b %d %T %Y' "$(ps -o lstart= -p "$1" | tr -s ' ')" +%s 2>/dev/null) || return
# fresh session: its transcript is created shortly after process start;
# near-simultaneous launches contend for the same file, so claimed ones are out
bestd=1800
for f in "$PROJECTS"/*/*.jsonl; do
case "$claimed" in *" $f "*) continue;; esac
b=$(stat -f %B "$f" 2>/dev/null) || continue
d=$((b - start))
if [ "$d" -ge -120 ] && [ "$d" -le "$bestd" ]; then bestd=$d; best=$f; fi
done
echo "$best"
}
summarize() { # transcript -> stdout summary
tail -c 150000 "$1" | with_timeout 90 "$CLAUDE_BIN" --model haiku -p \
"These are the trailing JSONL transcript lines of a Claude Code session being closed to free memory. Write a compact plain-text recap for its terminal tab: first line 'Topic: ...'; then 3-5 short '-' bullets of what was done or decided; last line 'Pending: ...' only if something was left unfinished. No markdown, under 120 words." 2>/dev/null
}
last_activity() { # transcript -> epoch of last real message (mtime is unreliable:
# Claude Code bulk-touches transcript mtimes, e.g. during startup grooming)
local iso
iso=$(tail -c 50000 "$1" | grep -oE '"timestamp":"[0-9T:.-]+' | tail -1 | cut -d'"' -f4)
if [ -n "$iso" ]; then TZ=UTC date -j -f '%Y-%m-%dT%H:%M:%S' "${iso%%.*}" +%s 2>/dev/null && return; fi
stat -f %m "$1"
}
while read -r pid cpu tty args; do
[ "$killed" -ge "$MAX_KILLS" ] && break
cmd=${args%% *}
[ "${cmd##*/}" = "claude" ] || continue
[ -n "$ONLY_PID" ] && [ "$pid" != "$ONLY_PID" ] && continue
case " $args " in *" -p "*|*"--print"*|*" mcp "*) continue;; esac
[ "$tty" = "??" ] && continue
[ -e "/dev/$tty" ] || continue
[ "${cpu%.*}" -eq 0 ] 2>/dev/null || continue
idle=$(( now - $(stat -f %a "/dev/$tty") ))
[ "$idle" -ge $(( IDLE_HOURS * 3600 )) ] || continue
sess=$(session_file "$pid" "$args")
if [ -z "$sess" ] || [ ! -f "$sess" ]; then
log "skip pid=$pid tty=$tty: no transcript mapping"
continue
fi
claimed="$claimed$sess "
quiet=$(( now - $(last_activity "$sess") ))
if [ "$quiet" -lt $(( QUIET_MINS * 60 )) ]; then
log "skip pid=$pid tty=$tty: transcript active ${quiet}s ago ($(basename "$sess"))"
continue
fi
sid=$(basename "$sess" .jsonl)
rss_mb=$(( $(ps -o rss= -p "$pid" | tr -d ' ') / 1024 ))
if [ -n "$DRY_RUN" ]; then
log "DRY-RUN would reap pid=$pid tty=$tty rss=${rss_mb}MB tty-idle=$((idle/3600))h session=$sid"
continue
fi
log "reaping pid=$pid tty=$tty rss=${rss_mb}MB tty-idle=$((idle/3600))h session=$sid"
kill "$pid" 2>/dev/null || continue
for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || break; sleep 1; done
kill -0 "$pid" 2>/dev/null && { log "pid=$pid ignored SIGTERM, leaving it alone"; continue; }
killed=$((killed + 1))
summary=$(summarize "$sess")
[ -n "$summary" ] || summary="(summary unavailable — transcript intact)"
{
printf '\n\033[2m────────────────────────────────────────────\033[0m\n'
printf '\033[1m💤 Idle Claude session closed to free %sMB\033[0m (no input for %sh)\n\n' "$rss_mb" "$((idle/3600))"
printf '%s\n\n' "$summary"
printf '\033[1m▶ Pick up where you left off:\033[0m claude --resume %s\n' "$sid"
printf '\033[2m────────────────────────────────────────────\033[0m\n'
} > "/dev/$tty" 2>/dev/null
done < <(ps -axo pid=,%cpu=,tty=,args=)
log "done: reaped $killed session(s)"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment