Skip to content
theQuertPublic

About

Three-layer automated cleanup for orphan Claude Code processes (subagents, MCP servers, plugins)

Resources

Stars

19 stars

Watchers

0 watching

Forks

Repository files navigation

cc-reaper

Automated cleanup for orphan Claude Code/Codex processes and settled git worktrees that leak memory or disk after sessions end.

The Problem

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.

What leaks

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.

Solution: Three-Layer Defense

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

Why PGID?

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.

Quick Start

git clone https://github.com/theQuert/cc-reaper.git
cd cc-reaper
chmod +x install.sh
./install.sh

Updating:

git pull
./install.sh

Is 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.sh

See 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 directory

Manual Setup

1. Shell Functions

Add to ~/.zshrc or ~/.bashrc:

source /path/to/cc-reaper/shell/claude-cleanup.sh
source /path/to/cc-reaper/shell/cc-monitor.sh

Commands 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 immediately
  • cc-monitor --json — emit structured JSON for future automation
  • cc-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 TTY
  • claude-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 RAM
  • claude-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 sessions
  • claude-guard --dry-run — preview what claude-guard would kill without actually killing

2. Claude and Codex lifecycle hooks

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.sh

For 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.sh

Add 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 --user manager (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.

3. Background Daemon (choose one)

Option A: LaunchAgent (zero-dependency, macOS only)

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 sed below must resolve $HOME to a real path. If it expands empty (e.g. under sudo), the plist gets a broken /.cc-reaper/... ProgramArguments and the agent silently fails every run with last exit code = 78 — verify with launchctl print gui/$(id -u)/com.cc-reaper.orphan-monitor | grep program. install.sh now 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.plist

Useful 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  # stop

Option B: proc-janitor (feature-rich)

Rust-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.toml

Edit ~/.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 manual

Useful commands:

proc-janitor scan     # dry run — show orphans without killing
proc-janitor clean    # kill detected orphans
proc-janitor status   # check daemon health

Automatic Session Guard

claude-guard is an automatic session reaper that prevents runaway resource consumption. It operates in three phases:

  1. FD-leak session kill — Sessions whose open file descriptor count exceeds CC_MAX_FD are 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.
  2. Bloated session kill — Sessions whose tree RSS (process + all children) exceeds CC_MAX_RSS_MB are 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.
  3. 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

Configuration

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-cleanup

CC_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.

Stop Hook Configuration

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 --user manager 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=1

This 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.

Heat Diagnostics

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 output

The 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.

Optimize after monitoring

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.

Stuck/runaway processes

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

Resource & Disk Janitor

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.

Reclaim contract for producers

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.

  1. One owner per resource. The producer bounds its own cache with a size or age cap. cc-reaper monitors it through a growth-targets.tsv key 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.
  2. The producer ends what it starts. Scratch, copies and checkouts are deleted in a trap or finally on every exit path, not only on success. A record that must outlive the run goes to a small, named path.
  3. Session byproducts never land in a worktree. Scratch belongs in the session scratchpad or the job's tmp/. A resumed job reuses its tmp/, 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.
  4. 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.sh and 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.
  5. 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

Growth watch

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 crossed

Worktree schedule

The 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 StartInterval

This 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.

Tune worktree retention to your development pace

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.sh

The 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.

Observe claims and cleanup decisions

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.log

Every 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.

Hook ownership

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}]}
    ]
  }
}

WIP backup (opt-in)

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 claude process has its cwd in the worktree (lsof), or a file in it changed in the last 6 hours (.git and node_modules aside). Branches main and master (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-remote per 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 --delete

Restore 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 commit

Tune 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).

Session reaper (opt-in)

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/*.json with kind: interactive and a tmux pane 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 whose claude-task-worktree marker 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. /exit is 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.py

macOS Companion App (local)

cc-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.

Install the runtime scripts

Run the interactive installer once, or rerun it after updating cc-reaper:

cd /path/to/cc-reaper
./install.sh

The 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.

Build and open the app

From the repository root:

swift test
./script/build_and_run.sh

build_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.app

The normal launch opens the dashboard and adds cc-reaper to the macOS menu bar.

App surfaces

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.

Custom process rules

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.

Background verification

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.

Dependencies

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

File Structure

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

Changelog

See CHANGELOG.md for version history.

Related Issues

License

Apache 2.0

About

Three-layer automated cleanup for orphan Claude Code processes (subagents, MCP servers, plugins)

Resources

Stars

19 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages