Automated cleanup for orphan Claude Code/Codex processes and settled git worktrees that leak memory or disk after sessions end.
Claude Code spawns subagent processes and MCP servers for each session. When sessions end (especially abnormally), these processes become orphans (PPID=1) and keep consuming RAM and CPU — often 200-400 MB each, with some (like Cloudflare's MCP server) hitting 550%+ CPU. With multiple sessions over a day, this can accumulate to 7+ GB of wasted memory.
This is a widely reported issue affecting macOS and Linux users.
| Process Type | Pattern | Typical Size |
|---|---|---|
| Subagents | claude --output-format stream-json |
180-300 MB each |
| MCP servers (short-lived) | npx mcp-server-cloudflare, npm exec mcp-*, etc. |
40-110 MB each |
| claude-mem worker | worker-service.cjs --daemon (bun) |
100 MB |
| Agent browser sessions | agent-browser-darwin-arm64, Chrome-for-Testing with agent-browser-chrome-* profiles |
100-600 MB each |
| Puppeteer headless Chrome | Chrome/Chrome Helper with puppeteer_dev_chrome_profile-* profiles |
Can pin CPU/GPU |
| Codex background sessions | node /usr/local/bin/codex, @openai/codex/.../codex --yolo |
Session + MCP tree |
| File descriptors | VM processes, settings.json, MCP stdio pipes | ~6,200 FDs/hr leak rate |
Not killed: User apps and system services such as ChatGPT.app, cmux.app, Bitdefender, Spotlight (
mdworker/mds_stores), normal Chrome browsing, and web dev servers are protected. Long-running MCP servers shared across sessions (Supabase, Stripe, claude-mem, chroma-mcp, sequential-thinking variants) are also protected. (Cloudflare's MCP server is not protected — it tends to orphan and pin ~100% CPU, so it is reaped like any other orphan.) Stale browser/Codex cleanup only targets orphaned or old automation processes.
PGID-based process group cleanup is used by proc-janitor and manual tools. The Stop hook defaults to PPID=1 orphan-only cleanup for safety; CC_STOP_HOOK_AGGRESSIVE=1 restores broad PGID group cleanup. Pattern-based detection is kept as a fallback for edge cases.
Session ends normally
└── Stop hook — primary pass kills orphaned processes (reparented to PID 1, or on Linux to the user's `systemd --user` manager) in session's PGID; secondary pattern sweep catches orphans that escaped the group (e.g., via setsid). With `CC_STOP_HOOK_AGGRESSIVE=1`, skips the orphan-parent check but still protects ancestors and MCP whitelist.
Session crashes / terminal force-closed
└── proc-janitor daemon — scans every 30s, kills orphans after 60s grace
└── OR: LaunchAgent — zero-dependency macOS native, PGID group kill + PPID=1 fallback
Manual intervention needed
└── cc-monitor — explain current CPU heat by process family before cleanup
└── macOS companion app — menu bar status, dashboard, process rules, and confirmed cleanup
└── claude-cleanup — finds orphaned PGIDs and stale agent-browser/Puppeteer/Codex stragglers
└── claude-ram — check RAM/CPU usage breakdown with orphan visibility
Claude Code sessions are process group leaders (PGID = session PID). All spawned MCP servers, subagents, and their children inherit this PGID. For proc-janitor and manual tools (claude-cleanup, claude-guard), one kill -- -$PGID reliably cleans up everything in the group — including third-party MCP servers that pattern matching might miss. The Stop hook defaults to a safer PPID=1 orphan-only mode to avoid accidentally killing the active Claude CLI.
Safety: PGID cleanup only targets groups whose leader is a Claude CLI session (claude.*stream-json). It never matches by group membership — other apps like Chrome and Cursor have claude subprocesses in their process groups, so matching by membership would kill them.
git clone https://github.com/theQuert/cc-reaper.git
cd cc-reaper
chmod +x install.sh
./install.shUpdating:
git pull
./install.shIs what runs what this checkout ships? ./install.sh --check compares every deployed
script and LaunchAgent with the checkout, changes nothing, and exits 1 on any difference.
Policy files you tuned (*.conf, growth-targets.tsv) are listed, never counted as drift.
On macOS 14 or later, build and open the optional local companion after installation:
./script/build_and_run.shSee macOS Companion App (local) for its dashboard, process rules, safety behavior, and background verification mode.
The installer auto-updates hook and shell functions. For proc-janitor users, manually sync the config:
cp proc-janitor/config.toml ~/.config/proc-janitor/config.toml
# Edit the log path: replace ~ with your actual home directoryAdd to ~/.zshrc or ~/.bashrc:
source /path/to/cc-reaper/shell/claude-cleanup.sh
source /path/to/cc-reaper/shell/cc-monitor.shCommands available after restart:
cc-monitor— explain current CPU heat contributors by process family before cleanup (read-only)cc-monitor --once— take one process snapshot and return immediatelycc-monitor --json— emit structured JSON for future automationcc-monitor --apply <module>— sample, print report, then dispatch a cleanup module (skips menu/confirm; cannot combine with--json)cc-monitor --no-prompt— disable the interactive optimization menu on a TTYclaude-ram— show RAM/CPU usage breakdown with per-session details and orphan visibility (read-only)claude-fd— show file descriptor usage per session and VirtualMachine processes (read-only)claude-sessions— list all active sessions with idle detection and process tree RAMclaude-cleanup— kill orphan processes immediately (PGID group kill + pattern fallback, plus stale agent-browser/Puppeteer/Codex cleanup)claude-guard— automatic session reaper: kills FD-leaking, bloated (RSS > threshold), and excess idle sessionsclaude-guard --dry-run— preview what claude-guard would kill without actually killing
install.sh deploys both shared hook implementations under ~/.cc-reaper/. It also
migrates Claude's global ~/.claude/settings.json; Codex hook files are normally checked
into each repository, so pass that repository explicitly or edit its .codex/hooks.json.
Legacy Claude orphan cleanup registered on SessionEnd is moved to Stop during migration.
# Optional: migrate one or more Codex repositories while installing.
CC_REAPER_CODEX_REPOS="$HOME/GitHub/repo-a:$HOME/GitHub/repo-b" ./install.shFor a manual installation, copy both hooks to cc-reaper's own runtime directory:
mkdir -p ~/.cc-reaper
cp hooks/stop-cleanup-orphans.sh hooks/worktree-session-end.sh ~/.cc-reaper/
chmod +x ~/.cc-reaper/stop-cleanup-orphans.sh ~/.cc-reaper/worktree-session-end.shAdd the process cleanup command to the "Stop" array in Claude's global
~/.claude/settings.json or the repository's .codex/hooks.json:
{
"type": "command",
"command": "\"$HOME\"/.cc-reaper/stop-cleanup-orphans.sh",
"timeout": 15
}Full settings.json example
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$HOME\"/.cc-reaper/stop-cleanup-orphans.sh",
"timeout": 15
}
]
}
]
}
}Stop is emitted after a completed assistant turn and can happen many times in one
session. SessionEnd is emitted by the Claude or Codex host when that session/task is
actually closed; cc-reaper does not infer it from process age or transcript timestamps.
The host passes the event JSON on stdin. The shared SessionEnd entrypoint validates that
payload, protects its cwd, detaches the sweep, and returns promptly:
{
"type": "command",
"command": "\"$HOME\"/.cc-reaper/worktree-session-end.sh codex",
"timeout": 10
}Use claude instead of codex in Claude's global hook. A missing, ambiguous, or invalid
event cwd makes that SessionEnd sweep report-only; it never guesses which checkout just
closed.
Archiving a task in the Claude or Codex app is not guaranteed to emit SessionEnd.
Observed Codex app behavior changes the task to notLoaded and releases its writer lock
without running the repository hook. SessionEnd is therefore only a best-effort
low-latency trigger; the independent LaunchAgent schedule is the guarantee layer. Once the
writer lock is gone, the archived task no longer counts as live, but its archive/update
time starts a separate 48-hour recent-session lease. This prevents an old worktree from
becoming immediately removable after an accidental archive. Claude uses recent transcript
activity for the same bounded lease. Every other landed, content, holder, idle, lock, and
submodule gate still applies after the lease expires.
⚠️ Safety: The Stop hook now includes built-in safety mechanisms:
- Orphan-only filtering: By default, only kills processes whose parent has already exited — those reparented to an orphan parent. On macOS that is PID 1 (launchd); on Linux it is PID 1 or the invoking user's
systemd --usermanager (the per-user reparent target — Linux orphans land there, not on PID 1). This is the definitive indicator of orphan status — unlike TTY filtering, it works correctly in SSH, Docker, tmux, and all terminal environments. Active Claude sessions, subagents, and shared MCP servers (still parented by a live process) are never killed.- Ancestor protection: Walks the full process tree (
$$→ PID 1) and never kills any ancestor process. This prevents accidental termination of the Claude CLI when an intermediate shell is involved.- Environment variables: See Stop Hook Configuration for tuning options.
Native macOS approach — no Homebrew or Rust required. Runs every 10 minutes, detects orphans by PPID=1. It does not select by CPU: background test runs, builds and experiments started from Claude Code are orphaned and hot by nature, and a CPU pass here killed them (removed 2026-09-15; CC_RUNAWAY_ORPHAN_MIN_SEC no longer does anything). A stuck shared MCP server is signalled by claude-guard's runaway phase instead, which the guard LaunchAgent runs every 10 minutes. install.sh installs that agent and the manual steps below do not: a manual Option A install has no runaway coverage: claude-guard measures heat across its own runs, so only runs no more than 20 minutes apart - which the agent provides - ever select one.
Install gotcha: the
sedbelow must resolve$HOMEto a real path. If it expands empty (e.g. undersudo), the plist gets a broken/.cc-reaper/...ProgramArgumentsand the agent silently fails every run withlast exit code = 78— verify withlaunchctl print gui/$(id -u)/com.cc-reaper.orphan-monitor | grep program.install.shnow fails fast rather than installing a broken path.
mkdir -p ~/.cc-reaper/logs
cp launchd/cc-reaper-monitor.sh ~/.cc-reaper/
chmod +x ~/.cc-reaper/cc-reaper-monitor.sh
# Install and replace __HOME__ with actual path
sed "s|__HOME__|$HOME|g" launchd/com.cc-reaper.orphan-monitor.plist \
> ~/Library/LaunchAgents/com.cc-reaper.orphan-monitor.plist
launchctl load ~/Library/LaunchAgents/com.cc-reaper.orphan-monitor.plistUseful commands:
launchctl list | grep cc-reaper # check if running
cat ~/.cc-reaper/logs/monitor.log # view cleanup log
launchctl unload ~/Library/LaunchAgents/com.cc-reaper.orphan-monitor.plist # stopRust-based daemon with grace period, whitelist, and detailed logging. Requires Homebrew or Cargo.
# Install
brew install jhlee0409/tap/proc-janitor # or: cargo install proc-janitor
# Copy config
mkdir -p ~/.config/proc-janitor
cp proc-janitor/config.toml ~/.config/proc-janitor/config.toml
chmod 600 ~/.config/proc-janitor/config.tomlEdit ~/.config/proc-janitor/config.toml and replace ~ in the log path with your actual home directory.
Start daemon:
brew services start jhlee0409/tap/proc-janitor # auto-start on boot
proc-janitor start # or manualUseful commands:
proc-janitor scan # dry run — show orphans without killing
proc-janitor clean # kill detected orphans
proc-janitor status # check daemon healthclaude-guard is an automatic session reaper that prevents runaway resource consumption. It operates in three phases:
- FD-leak session kill — Sessions whose open file descriptor count exceeds
CC_MAX_FDare killed immediately. This addresses the widely reported FD exhaustion issue where VM processes leak ~6,200 FDs/hour, eventually causing system-wide "Operation not permitted" errors. - Bloated session kill — Sessions whose tree RSS (process + all children) exceeds
CC_MAX_RSS_MBare killed immediately via PGID, regardless of whether they're idle or active. This addresses the ~42 GB/hr memory leak caused by unreleased streaming ArrayBuffers. - Idle session eviction — If session count still exceeds
CC_MAX_SESSIONS, the oldest idle sessions are killed.
claude-guard # run the guard (kills bloated + excess idle)
claude-guard --dry-run # preview without killing| Variable | Default | Description |
|---|---|---|
CC_MAX_SESSIONS |
3 | Max allowed concurrent sessions before idle eviction |
CC_IDLE_THRESHOLD |
1 | CPU% below which a session is considered idle |
CC_MAX_RSS_MB |
4096 | Tree RSS threshold (MB); sessions exceeding this are killed regardless of activity |
CC_MAX_FD |
10000 | File descriptor threshold; sessions exceeding this are killed as FD-leak |
CC_AGENT_STALE_MINUTES |
360 | Age threshold for stale agent-browser, Puppeteer Chrome, and detached Codex/MCP cleanup |
CC_RUNAWAY_CPU |
80 | CPU% above which a process is treated as stuck/runaway (combined with CC_RUNAWAY_MIN). Shared by both tools |
CC_RUNAWAY_MIN |
30 in cc-monitor, 60 in claude-guard |
Monitor: elapsed age of a currently hot process. Guard: sustained CPU-time intervals across runs, keyed by PID and start time; setting this variable overrides both floors |
CC_RUNAWAY_GRACE_SEC |
5 | Initial wait before a further three-second re-check of each selected MCP server; only the same still-hot PID is signalled |
CC_RUNAWAY_DISABLE |
0 | Set to 1 to skip claude-guard's runaway phase entirely |
Example: lower the thresholds for constrained machines:
export CC_MAX_RSS_MB=2048
export CC_MAX_FD=5000
export CC_AGENT_STALE_MINUTES=120
claude-guard
claude-cleanupCC_AGENT_STALE_MINUTES is used by claude-cleanup and the LaunchAgent monitor. Lower it only if browser automation frequently leaks on your machine; the default is intentionally conservative.
These environment variables control the Stop hook behavior. They are checked each time the hook runs.
| Variable | Default | Description |
|---|---|---|
CC_STOP_HOOK_DISABLE |
0 | Set to 1 to skip all cleanup (the hook becomes a no-op). Useful if the hook interferes with your workflow. |
CC_STOP_HOOK_AGGRESSIVE |
0 | Set to 1 to skip orphan-parent filtering and kill PGID members (still skips ancestor PIDs and MCP whitelist). By default, the hook only kills truly orphaned processes. |
Why orphan-parent filtering?
A process is orphaned once its original parent has exited and the OS reparents it. macOS reparents to PID 1 (launchd). Linux reparents a process whose login/session scope is gone to the invoking user's systemd --user manager, not PID 1 — so a PID=1-only check misses every orphan on Linux. cc-reaper builds an orphan-parent set once per run: PID 1, plus this user's systemd --user manager(s) if present (matched by UID, so another user's manager is never included; the manager PID itself is a reparent target, never a kill candidate). A process whose PPID is in that set is truly orphaned and safe to reap. TTY filtering is not used because:
- In SSH, Docker, and remote terminal environments, all processes have TTY=
?— TTY filtering would be a no-op (kill nothing) or dangerous (kill everything including the Claude CLI). - On macOS, orphans show TTY=
??while on Linux they show TTY=?— handling both requires platform-specific code. - Orphan-parent filtering is universal: works identically on macOS, Linux, in containers, and over SSH. On macOS / hosts without a
systemd --usermanager the set is exactly{1}, so behavior is unchanged.
When to disable the Stop hook:
# Option A: Disable temporarily for the current terminal session
export CC_STOP_HOOK_DISABLE=1
# Option B: Add to ~/.zshrc or ~/.bashrc for permanent disable
echo 'export CC_STOP_HOOK_DISABLE=1' >> ~/.zshrc
# Option C: Remove from settings.json entirely (see manual setup section)When to use aggressive mode:
If you notice active subagents or MCP servers still parented by a dying session that are not being cleaned up (because their PPID != 1 while the parent is in the process of exiting), enable aggressive mode:
export CC_STOP_HOOK_AGGRESSIVE=1This restores the original PGID cleanup that kills PGID members regardless of orphan status (ancestors and MCP whitelist still protected).
Caveat: user-managed daemons (LaunchAgent / systemd):
The pattern-based fallback in the Stop hook also sweeps orphan-parent processes globally (not scoped to the session's PGID). Shared MCP servers are protected by MCP_WHITELIST, but if you run a long-lived daemon under launchctl / systemd whose command matches one of the cleanup patterns — e.g., a headless claude --stream-json workflow or a worker-service.cjs --daemon, whether started by a LaunchAgent or a systemd --user unit — it is legitimately parented to an orphan parent and will be killed when any Stop hook fires.
If this applies to you, choose one:
# Easiest: disable the Stop hook entirely (other layers — proc-janitor / LaunchAgent — still clean up orphans)
export CC_STOP_HOOK_DISABLE=1
# Or: extend MCP_WHITELIST in hooks/stop-cleanup-orphans.sh to include your daemon's command pattern.Run cc-monitor when the laptop is hot and you want to understand the cause before cleaning anything:
cc-monitor # sample for 60s at 5s intervals; progress prints to stderr
cc-monitor --once # immediate snapshot
cc-monitor --json # machine-readable outputThe monitor is read-only. It groups processes into families such as editor, cmux, Codex, Claude, MCP, agent-browser, Chrome, dev server, system, and other. Each finding is classified as:
| Classification | Meaning |
|---|---|
SAFE_TO_REAP |
Stale or orphaned process that matches existing cc-reaper cleanup criteria |
ASK_BEFORE_KILL |
Active user tool or recent automation; inspect before stopping |
DO_NOT_KILL |
System, security, UI, or normal browsing process |
JSON output includes command strings for automation, with common token/key/secret/password argument values redacted.
After printing the report, cc-monitor can dispatch the right cleanup module so you don't have to switch commands.
Interactive mode (default on a TTY when the report has SAFE_TO_REAP candidates or family-level heat):
$ cc-monitor --once
=== cc-monitor: heat attribution ===
... report ...
Optimization options:
1. claude-cleanup (kill all stale orphans) (recommended)
2. claude-guard --dry-run (preview only)
3. proc-janitor scan (preview only)
4. skip
> 1
Run claude-cleanup (kill all stale orphans)? [y/N] y
The recommended option is claude-cleanup when stale/orphan candidates exist, otherwise claude-guard --dry-run when family RSS or per-process CPU is high. The menu is skipped when no candidates exist, on --json, when stdin/stdout is not a TTY, or when --no-prompt is passed. Modules whose binary is not on PATH are hidden from the menu and listed below with an install hint.
Script-friendly mode with --apply:
cc-monitor --once --apply claude-cleanup # kill stale orphans
cc-monitor --once --apply claude-guard-dry # preview-only
cc-monitor --once --apply proc-janitor-scan # preview-only via daemon--apply skips the confirmation prompt — the flag is itself the explicit opt-in. It cannot be combined with --json (exit 2). Module exit codes propagate.
Long-running MCP servers, dev servers, and security daemons are intentionally protected — claude-cleanup will never kill them. But "protected" is not absolute: a process pinned at high CPU for hours is broken, regardless of category.
Runaway is a claim about behaviour, so since 2026-08-30 any non-immutable process can be identified as one — not only those already on the protection list, which was backwards: the processes that run away are the ones nobody listed. Protection still decides what may be done about it.
Reporting and reaping use different floors, deliberately.
| threshold | acts on | |
|---|---|---|
cc-monitor (reports, never kills) |
CPU ≥ CC_RUNAWAY_CPU (80) for ≥ CC_RUNAWAY_MIN minutes (30) |
any non-immutable process |
claude-guard (SIGTERMs) |
CPU ≥ CC_RUNAWAY_CPU (80) for ≥ CC_RUNAWAY_MIN minutes (60) |
whitelisted protected MCP servers only |
The monitor reports current heat and elapsed age. The guard requires CPU-time samples across runs for 60 minutes, resets after gaps over 20 minutes, and selects only a known shared MCP server executable. It rechecks identity and CPU, then signals that PID alone. Failed sample recording selects nothing and logs a warning.
cc-monitor reclassifies the finding to family runaway / ASK_BEFORE_KILL and prints a dedicated section with a copy-pasteable kill line. The suggested action differs by protection status, because claude-guard filters through its whitelist and suggesting it for an unlisted process would name a remedy that does nothing:
Stuck/runaway processes:
PID 9594 node avg 102.70% etime 09:07:51 — appears stuck (sustained high CPU over long elapsed time); review and kill if not actively serving
suggested: kill 9594
A process carrying an Always Protect user rule is still labelled a runaway; its suggested action stays the Always Protect wording.
claude-guard adds a Phase 0.5 that reaps these PIDs in PGID-aware mode after CC_RUNAWAY_GRACE_SEC (default 5) seconds, so you can Ctrl+C if the report surprises you:
=== Claude Guard ===
Config: max_sessions=3, idle_threshold=1%, max_rss=4096 MB, max_fd=10000, runaway=80%/60min
--- Runaway protected processes (CPU >= 80% for >= 60 min) ---
PID 9594 CPU 102.7% ETIME 09:07:51 node /Users/.../mcp-server-cloudflare run abc
Sending SIGTERM in 5 seconds (Ctrl+C to abort)...
Reaped 1 runaway protected process(es), freed ~340 MB
Set CC_RUNAWAY_DISABLE=1 to skip the runaway phase entirely. JSON consumers see runaway entries in the existing findings array (with family: "runaway") plus a dedicated runaway_candidates array.
Example:
=== cc-monitor: heat attribution ===
Sample: once, snapshots: 1
Mode: read-only (no signals sent)
Top contributors:
1. cmux pid 62199 avg 93.00% max 93.00% rss 561 MB ASK_BEFORE_KILL cmux
2. WindowServer pid 384 avg 14.90% max 14.90% rss 128 MB DO_NOT_KILL system
Safe cleanup candidates:
PID 37915 agent-browser avg 0.00% max 0.00% - stale or orphaned browser automation matches cc-reaper cleanup criteria
Beyond process hygiene, cc-reaper ships three system-level janitors (added after a real incident: Load 31 on 12 cores / 95% disk, resolved manually then scripted). All background agents run with Nice 10 + LowPriorityIO — near-zero foreground impact.
| Script | Schedule | What it does |
|---|---|---|
resource-watch.sh |
every 10 min (launchd) | Single-pass snapshot (load / CPU idle / memory / disk) to ~/.cc-reaper/logs/resource-watch.log; macOS notification when load > 2× cores, disk free < 15%, disk free fell 10 GB or more against the sample from about 30 minutes earlier (ALERT:disk-drop), or memory is critically tight. Per-metric 60-min cooldown. |
disk-janitor.sh --check |
hourly (launchd) | Read-only: disk free % + Time Machine local-snapshot pin detection (snapshots holding freed space hostage after big deletes), plus a report of stale scratch checkouts under /private/tmp — directories nothing has open, holding no git repository, older than CC_DJ_TMP_AGE_DAYS and over CC_DJ_TMP_MIN_MB. It names them and never removes them: /private/tmp is world-writable and age plus size does not establish that something is abandoned. It also runs the growth watch. Below CC_DJ_ACT_FREE_GB (30) free it acts, at most once per CC_DJ_ACT_COOLDOWN_SECS (3h) under a lock: the skills hook's go cache pressure trim (reclaim-byproducts.sh --go-cache), then worktree-janitor.sh --trim-regenerable --apply stopped after about CC_DJ_ACT_TRIM_SECONDS (900; the janitor is given half of it as its sweep budget, so it yields and keeps its cursors before that kill), and comments the before/after on CC_DJ_ALERT_ISSUE (owner/repo#N) when set. CC_DJ_ACT_FREE_GB=off turns that off. |
disk-janitor.sh --clean |
Sunday 04:00 (launchd) | Cleans rebuildable-only targets: go build cache entries unused for CC_DJ_GO_CACHE_TRIM_DAYS (3) days - never the whole cache, nothing while a go build or test runs, and off where another reclaimer owns that cache - plus yarn / pip / brew / bun caches, the npm content cache (npm cache clean --force; a counted SKIP while an npm install, ci, update or add (or an alias) runs, while any process holds a file open under ~/.npm/_cacache, or when the open-file listing fails; CC_DJ_NPM_CACHE=off turns it off), ~/.npm/_npx installs unmodified for CC_DJ_NPX_TRIM_DAYS (14) days that no running process's command line, working directory or open file is inside, each rechecked just before removal and renamed to a tombstone first, and a tombstone a killed run left is removed by the next clean (nothing when a process or open-file listing fails; off turns it off), the Go module cache via go clean -modcache only below CC_DJ_DISK_MIN_PCT free and never while a go build, go mod / get / list / work, gopls or toolchain process runs (CC_DJ_GO_MODCACHE=off turns it off), Spotify / ShipIt / CoreSimulator caches, a report of docker dangling images and of unreferenced volumes with docker-generated-looking names, which are never deleted, TM snapshot thinning (dated com.apple.TimeMachine.* only, only when disk is below threshold). When the disk is below CC_DJ_DISK_MIN_PCT, it also delegates worktree-preserving cache trimming to worktree-janitor.sh --trim-regenerable --apply; holder, active-session, recent-claim and credential checks remain in that janitor. Tools are resolved from a known directory list, not the caller's PATH, because launchd supplies neither Homebrew nor Docker. Docker builder cache unused for at least 168 hours is pruned with docker builder prune --force --filter until=168h; images, containers and volumes are never removed. |
worktree-janitor.sh |
every 6 hours by default, Claude/Codex SessionEnd, or manual | Inventories ordinary source roots plus ~/.claude/worktrees and ~/.codex/worktrees, deduplicated by git common directory. A worktree is REMOVABLE only when all checks succeed: only regenerable content remains; no process has a cwd or open file there; no verified-live Claude/Codex session claims it by cwd or structured tool call; no mapped harness activity falls within the 48-hour recent-session lease; its work landed on the freshly fetched base by ancestry, content, or exact-head merged PR; it has been untouched for 48 hours; and it is neither locked nor carrying a populated submodule. The same activity, content, HEAD, idle and git-state gates are repeated immediately before removal. Direct invocation is dry-run unless --apply; the installed policy separately opts SessionEnd and the scheduled run into apply. --trim-regenerable is a narrower pressure pass: it removes only ignored built-in cache directories from an unheld, unclaimed worktree and keeps the branch and worktree. It is report-only without --apply, and rechecks holders and claims before each directory. --claims [id|path] exposes live and recent claims read-only; --landed PATH exposes the shared landed proof. The installer boots out and archives the legacy Claude worktree LaunchAgent so two destructive policies cannot race. Branches are never deleted and removal never uses --force. Policy lives in ~/.cc-reaper/worktree-janitor.conf, not Claude or Codex settings. See the reclamation method. |
Everything a CI flow, dev-workflow, a hook, a loop or a release driver writes to this host has to be reclaimable without guessing. On 2026-10-02 every leak found had the same shape: a producer with no end of its own, and a reclaimer that could not prove the thing was abandoned. Producers and cc-reaper both follow these five rules.
- One owner per resource. The producer bounds its own cache with a size or age cap.
cc-reaper monitors it through a
growth-targets.tsvkey and adds a second reclaimer only when the producer has no end of its own. Two reclaimers on one cache fight: one removed a file the other kept, and each emptied the pip cache before the other ran. - The producer ends what it starts. Scratch, copies and checkouts are deleted in a
traporfinallyon every exit path, not only on success. A record that must outlive the run goes to a small, named path. - Session byproducts never land in a worktree. Scratch belongs in the session
scratchpad or the job's
tmp/. A resumed job reuses itstmp/, so cc-reaper removes only a virtualenv from it, never the job's other files. A rebuildable worktree byproduct is declared in the repository's.worktree-regenerable(archive:for a record that must survive). The janitor removes worktrees without--force, so one stray untracked file keeps the whole tree. - A background process names its owner. A dev server or stack is started through
something that records its pid (stima-api:
scripts/dev/local_stack.shand its lease) and is stopped when its session or job ends. A server left behind with&outlives the session and pins its worktree as a process holder. - A reclaimer proves abandonment; age alone is not proof. It checks for a holder
(
lsof, a command line naming the path), checks that the owner has ended, and applies an age floor. Any probe that fails or cannot decide keeps the item.
What cc-reaper reclaims under rule 5 on its own:
| What | Rule |
|---|---|
Orphaned headless Chrome, cdp-* profiles, wrangler-*.log |
host-temp-reaper.py, idle limit, lsof |
$TMPDIR/nimbus-articles-build-* |
24h, lsof +D |
A tool's own temp leftover directly in $TMPDIR: tmpXXXXXXXX, tmp.XXXXXXXXXX, pip-…-XXXXXXXX, go-build<N>, nimbus-…-XXXXXX |
Nothing under it changed for 24h (72h for the generic tmp names, the age macOS clears at boot), no open file or cwd of this user's processes inside it (one lsof -u per run), no command line names it |
A virtualenv (pyvenv.cfg) in ~/.claude/jobs/<8 hex>/tmp of a done or stopped job |
72h after both its last terminal time and state.json mtime; no command line names it; lsof +D; job rechecked just before removal. blocked, working or an unreadable state is kept |
A dev server (npm run dev/preview/start, wrangler dev, next dev/start, vite, react-scripts start) that serves nobody |
dev-server-reaper.py from the hourly --check: launcher reparented to launchd, 6h old, cwd in a linked worktree, no agent in the tree or its process group, no live session claim (worktree-janitor.sh --claims), nothing publishing it (a listening process taking its worktree as an argument, such as a review front, or a tailscale serve handler to its port), and either not listening on the --port it names or the 20 above it (stopped at once), or, for a hot-reload dev server (react-scripts start, next dev, vite but not vite preview, webpack serve, judged from the commands its tree runs), listening with no TCP connection to that port (any port it listens on, for react-scripts start) at every hourly run, no two more than 2h apart, for 12h - 3h while the kernel memory pressure level is 2+, now or in any sample of the last hour in CC_DEV_SERVER_PRESSURE_LOG (mp=<level> lines; unset by default, export it from disk-janitor.conf) - and again before stopping. It runs beside the rest of --check, not after it, so a slow low-disk trim cannot delay it. A listening server without hot reload (next start, vite preview, wrangler dev) holds no connection between requests and is always taken as serving. TERM, then KILL after 10s, each pid only while its start time matches. CC_DJ_DEV_SERVER_REAP=report (default) lists, apply stops and comments on CC_DJ_ALERT_ISSUE. A tree with a connection or a publisher is only listed. The same pass stops an orphaned esbuild --service (the stdio build service a wrangler dev or vite leaves when it dies): a node_modules esbuild binary reparented to launchd, 6h old, no child, no socket but its dead stdio pair, no agent or launcher left in its process group, cwd in a linked worktree with no live claim, rechecked before the signal |
| Worktrees | worktree-janitor.sh (landed, unheld, no session, idle) |
Session scratchpads (/tmp/claude-<uid>/<project>/<session>), anonymous Docker volumes, the go build cache, old reclaim archives, dead caches you list |
reclaim-byproducts.sh under its own LaunchAgent com.cc-reaper.reclaim-byproducts (every 3h and at login, --if-stale): a scratchpad only when the harness's ~/.claude/hooks/path-in-use.sh says free, idle 24h (6h under disk pressure with no live session record). Schedule it once with ~/.cc-reaper/reclaim-byproducts.sh --install-launchd 3h; the installer fires the real agent as a dry run first and retires the skills-era com.claude.reclaim-byproducts. --dry-run reports, --self-test runs the suite from a checkout |
| OrbStack guest page cache holding host memory (macOS pressure never reaches the Linux guest) | orbstack-memory-trim.sh under com.cc-reaper.orbstack-memory-trim, every 15 min: when the OrbStack VM process holds more than a quarter of RAM (ORBSTACK_TRIM_LIMIT_MIB), a throwaway privileged container on the orbstack context drops the guest's clean caches. Nothing restarts or is deleted; a hung trim is killed after ORBSTACK_TRIM_TIMEOUT_SECONDS (120). No-op without OrbStack. Log: ~/.cc-reaper/logs/orbstack-memory-trim.log |
A fall in disk free says space went somewhere; it does not say where. Every hourly --check
runs growth-watch.py, which samples the targets in ~/.cc-reaper/growth-targets.tsv
(CC_DJ_GROWTH_TARGETS) and flags the ones that grew. It is read-only.
One row per target, tab-separated: label, path, owner, and an optional alert threshold
in GB. A path with a glob wildcard is sampled one key per matching directory
(claude-worktrees/<name>), and the label records their total; docker:<type> reads one
category of docker system df (Images, Containers, Local Volumes, Build Cache) and
docker:volumes/<glob> reads matching volumes from one docker system df -v. ~ and {uid}
are expanded. The owner is free text printed on the alert - the CI volume, the hook or the
reclaimer that produces the growth - so the investigation starts from a component rather
than a path. The installer ships a generic config/growth-targets.tsv and never replaces an
edited copy; add the roots your own tools write to.
Walking a large tree is expensive (one 91 GB worktree root took more than six minutes at
background priority), so a key is measured at most once per CC_DJ_GROWTH_INTERVAL_HOURS
(6), never-measured and oldest first, under nice, with no new measurement after
CC_DJ_GROWTH_BUDGET_SECONDS (240) and each du stopped at CC_DJ_GROWTH_KEY_TIMEOUT (900).
A backlog drains over several runs. Samples go to ~/.cc-reaper/state/growth-samples.tsv
for 14 days; a target that cannot be read records denied, absent, timeout or error,
never a size.
Growth is measured against the newest sample at least CC_DJ_GROWTH_WINDOW_HOURS (24) old,
or before a day of history the oldest one at least 3 hours old. A key or total that grew by
its threshold (CC_DJ_GROWTH_ALERT_GB, 5; 0 on a row disables it) logs
ALERT:growth key=<key> owner=<owner> +<GB>GB in <hours>h now=<GB>GB to disk-janitor.log
and posts one notification per cooldown; every sampling run logs its three largest growers.
grep 'growth:' ~/.cc-reaper/logs/disk-janitor.log | tail # what was sampled and what grew
grep 'ALERT:growth' ~/.cc-reaper/logs/disk-janitor.log # thresholds crossedThe default LaunchAgent interval is 21,600 seconds (6 hours), with RunAtLoad enabled. It
also covers app actions such as archive that may release a session without dispatching the
repository's SessionEnd hook.
Choose a fixed interval at install time with
CC_REAPER_WORKTREE_INTERVAL_SECONDS; accepted values are 300 through 604,800 seconds:
# Every 12 hours. Repeat this value on later installer runs so the generated plist
# keeps the same operator-selected cadence.
CC_REAPER_WORKTREE_INTERVAL_SECONDS=43200 ./install.sh
# Verify the installed value.
plutil -p ~/Library/LaunchAgents/com.cc-reaper.worktree-janitor.plist | grep StartIntervalThis is a launchd interval measured from agent loading, not a wall-clock cron expression.
Changing it does not weaken either 48-hour gate or any active-session/content/landed
check. CC_WJ_SCHEDULE_APPLY=0 in ~/.cc-reaper/worktree-janitor.conf keeps the schedule
loaded but makes its runs report-only. To disable only this schedule while retaining the
hooks:
launchctl disable "gui/$(id -u)/com.cc-reaper.worktree-janitor"
launchctl bootout "gui/$(id -u)" \
"$HOME/Library/LaunchAgents/com.cc-reaper.worktree-janitor.plist"Rerunning install.sh enables and reloads the agent.
The 48-hour defaults are a policy choice, not a hard-coded assumption. A worktree is eligible only after both time windows have expired, and an active Claude/Codex claim, process holder, unlanded commit, unrebuildable file, worktree lock, or populated submodule still vetoes removal regardless of age.
| Setting | Default | Where to set it | Effect |
|---|---|---|---|
CC_WJ_IDLE_HOURS |
48 |
~/.cc-reaper/worktree-janitor.conf |
Minimum time since worktree files or git administrative state changed. |
CC_WJ_SESSION_GRACE_HOURS |
48 |
~/.cc-reaper/worktree-janitor.conf |
Lease after the latest mapped Claude transcript activity or Codex task update/archive. This protects an accidentally archived task even when its live writer lock disappears. |
CC_WJ_GIT_STATUS_TIMEOUT_SECONDS |
300 (5 minutes) |
~/.cc-reaper/worktree-janitor.conf |
Per-worktree bound for the ignored-file scan. A timeout keeps that worktree with an observable git status timed out reason; it never makes it removable. |
CC_WJ_FETCH_TIMEOUT_SECONDS |
180 (3 minutes) |
~/.cc-reaper/worktree-janitor.conf |
Per-repository fresh-base fetch budget. Low-priority scheduled I/O can need longer than an interactive fetch; expiry keeps every worktree in that repository and makes the run fail observably. |
CC_WJ_SWEEP_BUDGET_SECONDS |
1800 (30 minutes) |
~/.cc-reaper/worktree-janitor.conf |
Per-phase budget of an apply run (removal sweep, scheduled trim). Past it the phase finishes the current worktree, examines no more, releases the lock, logs budget exhausted, and exits 0; the next run resumes after the last worktree examined, starting with the repository after the last one in which it examined a worktree. 0 is unbounded. |
CC_WJ_LSOF_TIMEOUT_SECONDS |
120 |
~/.cc-reaper/worktree-janitor.conf |
Bound on each machine-wide lsof holder scan, retried once on a timeout. A failed scan keeps every worktree. |
CC_WJ_LOCK_SCAN_TIMEOUT_SECONDS |
30 |
~/.cc-reaper/worktree-janitor.conf |
Bound on the Codex writer-lock registry scan, retried once on a timeout. A failed scan keeps every worktree. |
CC_WJ_RECHECK_FRESH_SECONDS |
120 |
~/.cc-reaper/worktree-janitor.conf |
In a trim, a holder and session snapshot younger than this decides a later cache directory of the same worktree without a machine rescan. 0 rescans before every directory. |
CC_WJ_LAUNCHAGENTS_DIRS |
~/Library/LaunchAgents:/Library/LaunchAgents:/Library/LaunchDaemons |
~/.cc-reaper/worktree-janitor.conf |
Colon-separated directories whose *.plist files are read for the paths a launchd job runs from. A worktree containing any absolute path a plist names is KEEP(launchd-target:<Label>), in the report and again right before a removal; an unparseable plist keeps every worktree (KEEP(launchd-scan-unknown)). *.disabled* files are skipped. A plist whose program or script does not exist is listed as launchd-missing-program:<Label> <path>; the janitor never unloads or edits a job. |
CC_REAPER_WORKTREE_INTERVAL_SECONDS |
3600 (1 hour) |
Environment when running ./install.sh |
How often launchd checks. Accepted range: 300 through 604800; this changes detection latency, not eligibility. Repeat the value on later installs. |
CC_WJ_SESSION_APPLY |
1 |
~/.cc-reaper/worktree-janitor.conf |
0 makes SessionEnd-triggered sweeps report-only. |
CC_WJ_SCHEDULE_APPLY |
1 |
~/.cc-reaper/worktree-janitor.conf |
0 makes scheduled sweeps report-only. |
CC_DJ_TRIM_WORKTREES |
1 |
environment | 0 disables low-disk delegation from disk-janitor --clean to the worktree-preserving regenerable trim. |
CC_WJ_ROOT |
~/GitHub |
~/.cc-reaper/worktree-janitor.conf |
Colon-separated ordinary source roots to inventory. Claude/Codex-owned worktree roots are discovered separately. |
An active Codex app task can create its writer lock before the matching row appears in
~/.codex/state_5.sqlite. The janitor first gives a newly-created row a short bounded
retry, then maps the verified-open lock from the uniquely matching rollout's
session_meta under ~/.codex/sessions. Missing, duplicate, unreadable, mismatched, or
unsafe metadata fails the whole activity scan closed, so no worktree is removed. For an
alternate Codex data layout, CC_WJ_CODEX_SESSIONS can point a diagnostic invocation at
the active rollout registry.
For a slower workflow, extend both retention gates rather than only slowing the schedule. For example, this keeps settled worktrees for at least 14 days and checks twice a day:
# ~/.cc-reaper/worktree-janitor.conf
: "${CC_WJ_IDLE_HOURS:=336}"
: "${CC_WJ_SESSION_GRACE_HOURS:=336}"
: "${CC_WJ_SESSION_APPLY:=1}"
: "${CC_WJ_SCHEDULE_APPLY:=1}"
: "${CC_WJ_ROOT:=$HOME/GitHub}"
# Regenerate and reload the LaunchAgent with a 12-hour check interval.
CC_REAPER_WORKTREE_INTERVAL_SECONDS=43200 CC_REAPER_DAEMON=b ./install.shThe installer preserves an operator-edited ~/.cc-reaper/worktree-janitor.conf instead of
overwriting it. An explicit environment value overrides that file for one invocation, which
is useful for a report-only policy check:
CC_WJ_IDLE_HOURS=336 CC_WJ_SESSION_GRACE_HOURS=336 \
~/.cc-reaper/worktree-janitor.sh --repo "$HOME/GitHub/example"Do not set either time gate shorter than the longest normal pause in your workflow. Keeping the schedule frequent is safe: it only re-evaluates the gates, and cannot make a live or otherwise protected worktree removable.
The claims diagnostic never fetches, prunes, or removes anything. Filter by thread/session id or path to verify an archive lease directly:
# The archived Codex task should show state=archived, age, and remaining grace.
~/.cc-reaper/worktree-janitor.sh --claims 01a0ab07-8ed5-7110-85e0-2a8f91d59c44
# A path filter also shows recent structured tool calls when the task's own cwd stayed
# in the primary checkout.
~/.cc-reaper/worktree-janitor.sh --claims "$HOME/GitHub/example-worktrees/task"
# Full report-only classification for one repository. A leased checkout prints
# classification: KEEP(recent-session) plus a "session lease:" evidence line.
~/.cc-reaper/worktree-janitor.sh --repo "$HOME/GitHub/example"
# Under disk pressure, report ignored package/build trees without removing the worktree:
~/.cc-reaper/worktree-janitor.sh --repo "$HOME/GitHub/example" --trim-regenerable
# The disk janitor is the pressure-gated caller for the apply form:
~/.cc-reaper/worktree-janitor.sh --repo "$HOME/GitHub/example" --trim-regenerable --apply
# Observe unattended decisions and SessionEnd sweeps.
tail -f ~/.cc-reaper/logs/launchd-worktree-janitor-stdout.log
tail -f ~/.cc-reaper/logs/worktree-janitor-session.logEvery scheduled sweep is delimited by scheduled sweep started and scheduled sweep ended
records carrying timestamp, pid, elapsed seconds, and exit status. The installed stdout and
stderr files are bounded at 1 MiB per current file, with the prior segment retained as .old.
If a repository's fresh base cannot be fetched, every worktree in that repository is kept as
KEEP(base-unfetched) and the sweep exits non-zero so unattended monitoring cannot mistake a
safe but incomplete pass for a fully healthy one.
CC_WJ_SESSION_GRACE_HOURS controls the recent-session lease independently of
CC_WJ_IDLE_HOURS; both default to 48. Set either only with an explicit retention-policy
decision. An expired lease merely removes that one veto—the worktree must still pass every
other gate.
The inventory indexes each live/recent transcript's current-two-user-turn byte ranges once per activity snapshot rather than rereading a multi-megabyte transcript for every candidate worktree. Its first candidate query creates a private, ephemeral projection of normalized structured-tool inputs; later candidates use fixed-string matching without starting another interpreter for that transcript. Malformed relevant records fall back to the exact parser and remain fail closed. Filesystem bytes round-trip through surrogate escapes, and signal/exit cleanup removes the private projection directory. The projection is rebuilt for the pre-removal activity refresh. A Codex rollout moved by archive is remapped through the current state database and becomes path-scoped recent-session evidence instead of an active claim on unrelated worktrees.
Scheduled sweeps reuse unchanged offset indexes from
~/.cc-reaper/state/transcript-index. Reuse requires the same path, device, inode, size,
and nanosecond modification time; any change rebuilds the index. These files contain only
file identity and byte ranges, never transcript records or tool-call content.
The harness still decides when lifecycle events occur; cc-reaper owns only the two portable cleanup implementations below. Other safety, context, notification, validation, handoff, and repository-specific hooks remain owned by Claude/Codex or by the repository.
| Event | Typical frequency | Extracted into cc-reaper | Shared command |
|---|---|---|---|
Stop |
after each completed assistant turn | process-orphan cleanup | ~/.cc-reaper/stop-cleanup-orphans.sh |
SessionEnd |
once when the host closes a session/task | worktree inventory and gated reclamation | ~/.cc-reaper/worktree-session-end.sh <claude|codex> |
SessionStart, PreToolUse, PostToolUse, Notification |
host/repository-specific | no | remains in harness settings |
Safety boundaries (hard): never touches user data (~/Documents, ~/Downloads, ~/Desktop) unless an operator explicitly adds a repository root there, runs no docker prune except the weekly docker builder prune --force --filter until=168h, never removes an image, never removes any volume, worktree cleanup never kills processes or deletes branches, and a hook payload whose own cwd cannot be established turns that SessionEnd sweep into a report.
What the docker step does remove: build cache the daemon reports unused for at least 168 hours, and nothing else. BuildKit never prunes a record a running build holds, so a CI build keeps its cache. Dangling images are reported, not removed: on a shared host an untagged image is another user's build. The step once ran docker system prune -af, which reaches every unused tagged image including ones that take hours to rebuild; that is not "rebuildable-only" and is gone. Unused images survive.
Volumes are reported and never removed. A 64-hex name looks docker-generated but does not prove it — docker volume create accepts such a name from anyone, and docker volume inspect exposes no flag that separates the two — so the janitor prints a count and the command to review them, and leaves the decision to you.
Config via env: CC_RW_LOAD_FACTOR / CC_RW_DISK_MIN_PCT / CC_RW_DISK_DROP_GB / CC_RW_MEM_MIN_PCT / CC_RW_COOLDOWN_SECS (watch), CC_DJ_DISK_MIN_PCT / CC_DJ_COOLDOWN_SECS / CC_DJ_TMP_DIRS / CC_DJ_TMP_AGE_DAYS / CC_DJ_TMP_MIN_MB (disk — the stale temp report), CC_WJ_ROOT / CC_WJ_HARNESS_ROOTS / CC_WJ_IDLE_HOURS / CC_WJ_SESSION_GRACE_HOURS / CC_WJ_BASE_BRANCH / CC_WJ_SESSION_APPLY / CC_WJ_SCHEDULE_APPLY / CC_WJ_SESSION_LOG / CC_WJ_NOTIFY_MIN_GB / CC_WJ_TOOL_DIRS (worktree). The installed defaults are in ~/.cc-reaper/worktree-janitor.conf; an explicit environment value wins for a one-shot run.
Claude's global settings.json is migrated by install.sh. Codex hooks are repository-owned,
so check both shared triggers into each repository's .codex/hooks.json (or pass a
colon-separated list in CC_REAPER_CODEX_REPOS when installing):
{
"hooks": {
"Stop": [
{"hooks": [{"type": "command", "command": "\"$HOME\"/.cc-reaper/stop-cleanup-orphans.sh", "timeout": 15}]}
],
"SessionEnd": [
{"hooks": [{"type": "command", "command": "\"$HOME\"/.cc-reaper/worktree-session-end.sh codex", "timeout": 10}]}
]
}
}shell/wip-backup.sh keeps a snapshot of every active Claude Code worktree on its own origin,
at refs/wip/macmini/<branch>, so a Mac outage or a dead disk loses at most ten minutes of
uncommitted or unpushed work. Whoever takes over an issue can start from that snapshot
(stima-api docs/OPERATING-MODEL.md, Mac outage protocol). It runs every 10 minutes from
com.cc-reaper.wip-backup.
- Active means a live
claudeprocess has its cwd in the worktree (lsof), or a file in it changed in the last 6 hours (.gitandnode_modulesaside). Branchesmainandmaster(the canonical checkout) are skipped. - Snapshot: HEAD plus staged, unstaged and untracked non-ignored files. It is built in a
temporary
GIT_INDEX_FILE(read-tree,add -A,write-tree,commit-tree -p HEAD), so the worktree, its index, its stash and its branches are never touched. Files over 20 MB are left out and logged. - Push: only when the snapshot's tree or parent differs from the remote ref, and only that
ref is force-updated. A clean worktree whose HEAD is already pushed needs no snapshot. There is
one
git ls-remoteper repository per run, and none on an idle machine except an hourly cleanup. - Cleanup: a WIP ref is deleted only when no worktree has the branch checked out and no live session is in one. In addition, either the remote branch must be merged into the remote's default branch, or the branch must be gone from both the remote and the local repository with a snapshot more than 72 hours old. Branches are never deleted.
- Scratchpads (
/private/tmp/claude-<uid>/<project>/<session>/scratchpad) are pruned by the byproducts reaper, not here. Its rule is stricter than a plain age cut: idle 24h and free by the path-in-use liveness gate, never forced. Make sure that reaper is scheduled (below).
The label is host-specific, so install.sh does not enable the backup. Install it on the Mac
mini only, from a checkout at or after this change. Nothing else needs installing: git, lsof
and find are stock. The remote must be reachable without a prompt from a LaunchAgent: HTTPS
with the macOS keychain or gh auth setup-git, or SSH with a key that needs no agent prompt.
# install (Mac mini)
cd ~/GitHub/cc-reaper && git pull --ff-only
mkdir -p ~/.cc-reaper/logs ~/.cc-reaper/state
install -m 755 shell/wip-backup.sh ~/.cc-reaper/wip-backup.sh
~/.cc-reaper/wip-backup.sh --dry-run # what it would push and delete; changes nothing
cp launchd/com.cc-reaper.wip-backup.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.cc-reaper.wip-backup.plist
launchctl print gui/$(id -u)/com.cc-reaper.wip-backup | grep -E 'state|last exit'
tail -n 5 ~/.cc-reaper/logs/wip-backup.log # one line per push/delete, a "done:" line per run
# scratchpad pruning: schedule the byproducts reaper if it is not already
launchctl print gui/$(id -u)/com.cc-reaper.reclaim-byproducts >/dev/null 2>&1 ||
~/.cc-reaper/reclaim-byproducts.sh --install-launchd 3h
# uninstall: stops the agent; the refs already pushed stay until you delete them
launchctl bootout gui/$(id -u)/com.cc-reaper.wip-backup
rm -f ~/Library/LaunchAgents/com.cc-reaper.wip-backup.plist ~/.cc-reaper/wip-backup.sh
rm -rf ~/.cc-reaper/state/wip-backup
# optional, per repository: remove every snapshot this host pushed
git ls-remote origin 'refs/wip/macmini/*' | cut -f2 | xargs -n1 git push origin --deleteRestore a snapshot on another machine:
git fetch origin 'refs/wip/macmini/claude/3632-x:refs/wip/macmini/claude/3632-x'
git log -1 --stat refs/wip/macmini/claude/3632-x # parent = the branch HEAD it was taken on
git worktree add ../restore-3632 refs/wip/macmini/claude/3632-x # detached, with the WIP as one commitTune with ~/.cc-reaper/wip-backup.conf, which is sourced when present: WIP_REPOS (default
$HOME/GitHub/*), WIP_RECENT_MINUTES (360), WIP_MAX_MB (20), WIP_GONE_GRACE_HOURS (72),
WIP_SKIP_BRANCHES (main master).
shell/session-reaper.py closes interactive Claude Code sessions in tmux once their work is
finished, so tmux ls shows who is still working and idle sessions stop holding memory. It runs
every 30 minutes from com.cc-reaper.session-reaper and calls no model. It only reports until
the deployed config sets APPLY=1.
- Considered: sessions in
~/.claude/sessions/*.jsonwithkind: interactiveand atmuxpane that still holds the same pid and start time. Background sessions are never touched. - Kept when any gate holds; the report names it:
protected,status:<busy|shell>,idle:<n><threshold,shell-child,loop-pending,scheduled-task,waiting,claim-unknown,issue-open:#n,uncommitted:<worktree>,unpushed:<worktree>. - Done means the latest assistant text has a
SESSION-DONE: <summary>line or says the topic is finished, or every issue the session claimed is closed. Claims come from worktrees whoseclaude-task-worktreemarker names the session; the issue number is read from the branch. - Reap: first it re-checks that the session file is unchanged since the sweep read it, that
claude is the pane's foreground process, and that the current tmux name is not protected.
/exitis typed only at an empty prompt with no dialog on screen, and Enter is pressed only if the prompt then reads exactly/exit(otherwise the text is taken back out). If the background task menu appears, Esc selects Stay and the session is skipped. After claude exits its pane is closed, and the tmux session too when that was its only pane; other windows are left alone. The closed-sessions log and each claimed issue get the resume command. - Verify: the pre-exit process tree, the pane (and the tmux session when it was killed) and
the session file must be gone, and no owned worktree may be locked. A failure notifies,
comments on
TRACKING_ISSUE(orphans by executable name only), and exits 1.
# install (the host that runs the tmux sessions)
cd ~/GitHub/cc-reaper && git pull --ff-only
install -m 755 shell/session-reaper.py ~/.cc-reaper/session-reaper.py
[ -f ~/.cc-reaper/session-reaper.conf ] || install -m 600 config/session-reaper.conf ~/.cc-reaper/
~/.cc-reaper/session-reaper.py # report only; prints the report path
~/.cc-reaper/session-reaper.py --only <tmux> --apply # reap one session now
cp launchd/com.cc-reaper.session-reaper.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.cc-reaper.session-reaper.plist
# uninstall
launchctl bootout gui/$(id -u)/com.cc-reaper.session-reaper
rm -f ~/Library/LaunchAgents/com.cc-reaper.session-reaper.plist ~/.cc-reaper/session-reaper.pycc-reaper includes a native SwiftUI menu bar app for status visibility and safe manual actions. It reads the existing cc-monitor --once --json contract and delegates cleanup to the existing shell engine; process discovery, classification, and termination policy remain in the tested shell scripts.
Requirements: macOS 14 or later and the Swift toolchain from current Xcode or Xcode Command Line Tools.
Run the interactive installer once, or rerun it after updating cc-reaper:
cd /path/to/cc-reaper
./install.shThe installer deploys cc-monitor.sh, claude-cleanup.sh, and the supporting scripts under ~/.cc-reaper/, then installs or refreshes the selected background daemon and LaunchAgents. It does not copy the GUI into /Applications.
From the repository root:
swift test
./script/build_and_run.shbuild_and_run.sh builds the current checkout, stages an unsigned bundle at dist/CCReaper.app, stops only a previous app process from that staged bundle, and opens the current build. Once it has been built, it can also be opened directly:
open /path/to/cc-reaper/dist/CCReaper.appThe normal launch opens the dashboard and adds cc-reaper to the macOS menu bar.
| Surface | Available actions |
|---|---|
| Menu bar | View current cleanup/review/runaway counts, refresh status, preview cleanup, start cleanup review, open the dashboard or Settings, and quit the app. |
| Dashboard | View CPU/memory and process findings, filter by Cleanup/Review/Protected/All, inspect suggested actions, open logs, preview cleanup, and explicitly confirm eligible cleanup. |
| Settings | Inspect or override the active script root, configure monitor/refresh preferences, and add or remove custom process rules. |
Refresh is read-only. Preview Cleanup runs the same claude-cleanup --dry-run policy without sending signals. Actual cleanup is available only from the dashboard after a successful preview and an explicit destructive confirmation, then delegates once to the existing claude-cleanup function.
The app prefers a complete installed root at ~/.cc-reaper/. A bundle staged under dist/ falls back to the checkout's shell/ directory when the installation is incomplete, and an explicit Settings override takes precedence over both. Missing scripts, invalid JSON, incompatible monitor output, timeouts, and command failures display an unavailable/error state instead of reporting the system healthy.
The companion's Settings → Process Rules pane and each finding row's action menu can add a case-insensitive literal command match as either:
- Always Protect — matching processes are excluded from cleanup and guard termination paths.
- Allow Stale Cleanup — a matching ordinary process may become a cleanup candidate only when it is both stale and detached/orphaned. System/security/UI processes, normal Chrome, cc-reaper, and the Codex Computer Use helper remain immutable; preview and explicit cleanup confirmation are still required.
Rules are stored in ~/.cc-reaper/process-rules.tsv with owner-only permissions. protect wins conflicts, invalid externally edited rows are ignored, and removing the final rule removes the file. The match is literal text, not a regular expression or wildcard.
Always Protect rules are re-read by manual cleanup, guard paths, and the scheduled LaunchAgent before both TERM and SIGKILL, so changing a rule does not require reloading the agent.
Immutable findings do not offer Allow Stale Cleanup in their finding-row menu. Settings can persist any otherwise valid literal, but the monitor and cleanup engines still ignore cleanup effects that match an immutable process.
For automated launch checks while continuing other work, run ./script/build_and_run.sh --verify. Verification launches with background activation, confirms a new app process, stops only that verification process, and leaves both the foreground application and any pre-existing cc-reaper instance alone.
This local app is currently unsigned and repository-staged. Packaging into /Applications, code signing, notarization, and automatic updates are not included yet.
| Tool | Required | Install |
|---|---|---|
| bash/zsh | Required | Pre-installed on macOS/Linux |
| Git 2.38+ | Required for worktree and content-landed checks | Xcode Command Line Tools or your package manager |
| Python 3 | Required for hook migration and bounded Claude/Codex transcript-tail queries | Xcode Command Line Tools or your package manager |
jq |
Required for harness registry parsing | brew install jq |
sqlite3 |
Required when Codex task claims are present | Pre-installed on macOS |
lsof |
Required for cwd/open-file vetoes | Pre-installed on macOS/Linux |
GitHub CLI (gh) |
Optional exact-head merged-PR landed proof | brew install gh |
| macOS LaunchAgent | Option A (recommended) | Built-in, zero dependencies |
| proc-janitor | Option B | brew install jhlee0409/tap/proc-janitor |
| Claude Code | — | The tool this project cleans up after |
cc-reaper/
├── install.sh # One-command installer/updater (interactive daemon choice)
├── hooks/
│ ├── stop-cleanup-orphans.sh # Session orphan filtering + pattern fallback
│ └── worktree-session-end.sh # Shared Claude/Codex SessionEnd trigger
├── config/
│ ├── worktree-janitor.conf # 48h + scheduled/session apply policy
│ └── growth-targets.tsv # Growth watch targets template
├── launchd/
│ ├── cc-reaper-monitor.sh # LaunchAgent monitor script (PGID + PPID=1 fallback)
│ ├── com.cc-reaper.orphan-monitor.plist # LaunchAgent config (10-min interval)
│ ├── com.cc-reaper.resource-watch.plist # System snapshot agent (10-min interval)
│ ├── com.cc-reaper.disk-check.plist # Read-only disk check agent (hourly)
│ ├── com.cc-reaper.weekly-clean.plist # Rebuildable-cache clean agent (Sun 04:00)
│ ├── com.cc-reaper.orbstack-memory-trim.plist # OrbStack guest cache trim (15-min interval)
│ ├── com.cc-reaper.wip-backup.plist # Opt-in WIP snapshots to refs/wip/macmini/* (10-min interval)
│ └── com.cc-reaper.worktree-janitor.plist # Shared worktree sweep (6-hour interval)
├── proc-janitor/
│ └── config.toml # proc-janitor daemon config (alternative to LaunchAgent)
├── shell/
│ ├── cc-monitor.sh # Read-only heat attribution monitor
│ ├── claude-cleanup.sh # Shell functions (claude-ram, claude-fd, claude-cleanup, claude-sessions, claude-guard)
│ ├── resource-watch.sh # System snapshot + threshold alerting
│ ├── disk-janitor.sh # Disk check (--check) / rebuildable-cache clean (--clean)
│ ├── growth-watch.py # Budgeted per-target size samples + growth alerts
│ ├── orbstack-memory-trim.sh # Drops OrbStack guest caches when its VM holds over 1/4 of RAM
│ ├── wip-backup.sh # Opt-in: snapshot active worktrees to refs/wip/<label>/<branch>
│ └── worktree-janitor.sh # Git worktree inventory + gated removal (dry-run default)
├── tests/
│ ├── agent-process-patterns.sh # Cleanup-candidate matcher validation
│ ├── cc-monitor-optimize.sh # cc-monitor optimization menu tests
│ ├── cc-monitor-runaway.sh # Runaway protected process detection tests
│ ├── ppid-fallback.sh # PPID=1 fallback kill + whitelist validation
│ ├── resource-watch.sh # Snapshot / threshold / cooldown tests (stubbed)
│ ├── disk-janitor.sh # Read-only check / forbidden-flag / thinning tests (stubbed)
│ ├── growth-watch.sh # Sampling order / budget / status / growth alert tests (stubbed)
│ ├── wip-backup.sh # Snapshot contents / push-on-change / ref cleanup (bare remote)
│ └── worktree-janitor.sh # Fixture-repo gate + apply tests
└── README.md
See CHANGELOG.md for version history.
- anthropics/claude-code#20369 — Orphaned subagent process leaks memory
- anthropics/claude-code#22554 — Subagent processes not terminating on macOS
- anthropics/claude-code#25545 — Excessive RAM when idle
- thedotmack/claude-mem#650 — worker-service spawns subagents that don't exit
- anthropics/claude-code#29888 — VM process FD leak (~6,200/hr)
- anthropics/claude-code#28896 — settings.json FD leak (1 per tool call)
- anthropics/claude-code#37482 — MCP server stdio pipe breaks (orphaned FDs)
Apache 2.0