wreaper (a portmanteau of windowless + reaper) is a
macOS background daemon that politely terminates apps the user has forgotten
about — apps that have been running with no visible window for longer
than a per-app timeout. It is strictly allowlist-driven: only bundle IDs
named in the config are ever candidates.
- Whitelist only: no app is touched without an explicit rule
- Only windowless apps: visible or minimised windows are never touched
- Polite termination: always
terminate(), neverforceTerminate() - Daemon checks every 30s by default, with hot-reloadable config. Minimal system resources used
- Behaves well under sleep/wake — no false reaps after wake, and sleep does not advance timers
- CLI supports one-shot checks and clears for manual use
wreaper check/wreaper clear
macOS apps routinely linger after the last window is closed by clicking close (the red circle). This is great because it allows them to restart almost instantly, but they consume RAM and other system resources as a downside. wreaper monitors them and, once an app has been windowless for the configured timeout, sends a polite
NSRunningApplication.terminate(). If the app vetoes (e.g. unsaved work), the
timer resets and nothing is forced.
Only whitelisted apps are candidates, and no running or minimised window is ever closed. So there should be zero chance of losing work.
However use at your own risk!
I also have Raycast configured to call wreaper clear on opt+cmd+w which works really well.
Each v* tag publishes an ad-hoc signed binary, a tarball, and
SHA256SUMS to GitHub Releases:
curl -L -o wreaper \
https://github.com/<owner>/windowless_reaper/releases/latest/download/wreaper
chmod +x wreaper
# A browser download is quarantined; clear it once:
# xattr -dr com.apple.quarantine ./wreaperwreaper --version reports the tag and commit it was built from, e.g.
1.2.0 (3f29b14f08eb). Ad-hoc signing carries no Developer-ID identity, so
Accessibility is still granted manually (below). See DISTRIBUTION.md for the
signing-mode tradeoffs.
Requires Swift 6.2+ (pinned in .swift-version and Package.swift), tested
with the Xcode 16+ toolchain. SwiftPM only — there is no .xcodeproj or
.xcworkspace. To open in Xcode, open the package itself (open Package.swift,
or File → Open on the project folder); Xcode resolves it and uses its bundled
toolchain. Editing/browsing there is fine, but builds and tests go through
swift, and never commit generated .xcodeproj/.xcworkspace/scheme files.
scripts/dev-build.sh -c release # build, stamping the live git version
scripts/sign.sh # ad-hoc sign with a stable identifier
cp .build/release/wreaper /usr/local/bin/ # or $(brew --prefix)/bin
wreaper config init # writes ~/.config/windowless-reaper/config.tomlscripts/dev-build.sh stamps the live git describe version + commit into
--version and passes extra args through to swift build. A plain
swift build skips the stamp and reports 0.0.0-dev.
For a faster first pass at the app rules, wreaper config scaffold is often
the better starting point than editing from scratch: it inspects the current
running apps and emits a starter config.
Grant Accessibility permission once:
System Settings → Privacy & Security → Accessibility → + and add the
absolute path to wreaper. macOS does not expose this via API. Verify with:
wreaper permissions checkDISTRIBUTION.md covers the tag-triggered ad-hoc release workflow and the
signed/notarised release flow.
For more operational detail, see docs/wreaper_notes.md, which covers Accessibility grant/verification, daemon install/update, log locations, and launchctl lifecycle.
wreaper supports two operating modes:
- One-shot CLI commands: run once, print/report the result, then exit.
- A persistent
launchddaemon: run continuously in the background and reload config on save.
The two one-shot commands below are useful for manual verification and
explicit fire-and-exit use. For continuous background reaping, use the
launchd workflow in Live-running under launchd:
wreaper check— dry-run a single tick, print decisions, exit non-zero if any rule would evict. Good for verifying config.wreaper clear— terminate every allowlisted bundle that is fully windowless right now, honouringclear_cooldownso just-launched apps are spared. Designed for explicit one-shot use.
See Manually test before going live
for full output samples and flags. The launchd section below documents
the persistent background setup.
Configuration lives at ~/.config/windowless-reaper/config.toml. Edit it
freely — wreaper run reloads on save without a restart. poll_interval
changes take effect on the next tick after reload. log_level changes apply
unless --log-level was passed on the command line, in which case the CLI
wins.
For initial setup, the fastest workflow is usually:
- Run
wreaper config initto create the config file location. - Run
wreaper config scaffoldto generate starter app rules from the current process list. - Edit the generated TOML to keep only the bundle IDs you actually want allowlisted.
[settings]
poll_interval = "30s"
log_level = "info"
dry_run = false
default_cooldown = "5x"
default_timeout = "10m" # used by any rule with timeout = "default"
[apps."com.apple.Safari"]
timeout = "3m"
[apps."com.apple.mail"]
timeout = "10m"
cooldown = "20m"
[apps."com.tinyspeck.slackmacgap"]
timeout = "default"| key | type | meaning |
|---|---|---|
poll_interval |
duration | how often the engine ticks (e.g. 15s, 1m). Default 30s, minimum 10s. |
log_level |
string | trace / debug / info / notice / warn / error (warning accepted). |
dry_run |
bool | log decisions but never terminate. |
default_cooldown |
duration | applied when a rule omits cooldown. 5x means 5 × the rule timeout. |
default_timeout |
duration | fallback for any rule written as timeout = "default". Optional. |
clear_cooldown |
duration | wreaper clear skips bundles whose newest PID launched within this window. Default 30s, minimum 10s. Has no effect on run/check. |
| key | required | meaning |
|---|---|---|
timeout |
yes | continuous windowless time before termination. "none" keeps the entry inert; "default" inherits [settings].default_timeout. |
cooldown |
no | post-kill ignore window. Falls back to default_cooldown. "none" also falls back. |
Keys are CFBundleIdentifier strings — never display names. Apps not
listed here are never touched.
Duration syntax: 30s, 5m, 2h, 1d. Minimum 10s — anything shorter is a
load-time error. cooldown also accepts Nx (multiplier of this rule's
timeout).
wreaper status # AX window state for every regular app
wreaper config scaffold # only currently-windowless apps, timeout="none"
wreaper config scaffold --all-running
wreaper config scaffold --include-systemscaffold emits inert timeout = "none" entries; replace each with a real
duration before enabling the rule.
wreaper config validate # uses default path, exits non-zero on error
wreaper config validate ./alt.toml # validate a specific file
wreaper config show # parse + print canonical TOMLwreaper terminates real apps. Always exercise the config in dry-run mode
before letting launchd run it.
Runs a single dry-run tick and prints decisions. Exits 1 if at least one
allowlisted app would be reaped:
ignore com.example.notlisted age=4h12m
track com.tinyspeck.slackmacgap age=15m
would-evict com.apple.mail pids=[1742] age=2h
cooldown com.apple.Safari
age= shows how long ago the bundle's newest PID launched (omitted when
the workspace didn't record a launch date). This is the fastest way to
sanity-check a rule change.
Like check, but instead of waiting for each app's timeout, every
allowlisted bundle that is currently fully windowless is terminated in a
single pass. Runs without prompts so it can be invoked directly from the CLI
or other explicit automation:
skip com.tinyspeck.slackmacgap has-window age=5m
skip com.apple.Notes just-launched age=8s
terminated com.apple.mail pids=[1742] age=2h
vetoed com.apple.Safari pids=[2031] age=45m
Rules with timeout = "none" are skipped (allowlisted but inert).
Bundles whose newest PID launched within [settings].clear_cooldown
(default 30s) are left alone with just-launched — this stops the
command killing apps the user only just opened. PIDs without a known
launch date are treated as old enough to reap. --dry-run is honoured;
under dry-run, kill lines read would-evict … instead. Exits 0 once
the pass completes, regardless of how many apps were reaped or vetoed.
wreaper run --dry-run --log-level debugLogs go to stderr. Useful events to watch for:
starting config=… logLevel=debug pollInterval=… dryRun=truewould terminate <bundle> pids=[…]termination vetoed <bundle> — timer resetconfig reloaded path=…skipping tick after wake (grace period)after a sleep/wake.
Hot reload: edit the config in another shell and save — the running engine picks up the change without a restart.
Prints the window classification (visible / none / minimised) for every
regular app. Use this to confirm an app really is windowless before adding it
to the allowlist.
Renders version, AX trust state, effective log level / dry-run, config path, LaunchAgent install status, and recent decisions. Attach the output to bug reports.
The real AppKit/AX smoke tests are opt-in and only run from a GUI console session:
WREAPER_RUN_INTEGRATION_TESTS=1 swift test --filter Testing.Tag/integration- Pick one low-risk app and add a rule with a generous
timeout. - Set
dry_run = truein[settings]. - Run foreground for ≥ 30 minutes with
--log-level debug; confirm thewould terminate …lines name the bundles and PIDs you expect. - Install under launchd (next section) with
dry_run = truestill set. - Tail the launchd log for a day. Only then flip
dry_run = false— hot reload picks it up. - To back out at any time:
wreaper uninstall --user.
wreaper install --user is intended for routine background use under
launchd. Local soak testing has now run for roughly 14 days without
observed memory growth, sleep/wake regressions, or launchd stability
issues. That does not remove the need to validate your own rule set, but
the install path is no longer treated as experimental.
Operational details for install, update, restart, logs, and disable/uninstall are in docs/wreaper_notes.md.
First-time daemon install:
- Build and sign the binary.
- Copy it to its stable install path.
- Grant Accessibility to that exact path in System Settings.
- Verify with
wreaper permissions check. - Load the LaunchAgent with
wreaper install --user.
Updating the daemon after a rebuild:
- Rebuild and sign the new binary.
- Replace the binary at the same installed path.
- Restart the running agent with
launchctl kickstart -k gui/$(id -u)/<label>. - Confirm the agent is running and logging normally.
If the install path and signing identifier stay stable, you should not need to re-grant Accessibility on update.
wreaper install --user # write & load ~/Library/LaunchAgents/com.user.windowless-reaper.plist
wreaper install --user --print-only # preview the plist and launchctl actions
wreaper install --user --force # replace an existing install
wreaper install --user --prefix /opt/homebrew # override binary search prefix
wreaper uninstall --userThe plist runs wreaper run with RunAtLoad=true and KeepAlive=true, as
ProcessType=Background.
A foreground wreaper run is not the same evidence as the same binary
running under launchd: signal delivery, session type, log routing, and
whether NSWorkspace notifications arrive in the agent's session all
differ. Before flipping dry_run = false, soak the installed daemon for
at least 72 hours and diff its runtime-health counters.
The engine emits a runtime-health log line on the first tick and again
once every hour after that (see ReaperEngine.healthLogInterval). One
line per hour is plenty for a 72hr window without drowning the log.
- Install the daemon (build + sign + load).
scripts/dev-build.sh -c release
scripts/sign.sh
cp .build/release/wreaper "$(brew --prefix)/bin/"
wreaper install --userTo uninstall later:
wreaper uninstall --user
Inspect runtime status anytime with launchctl print gui/$(id -u)/<label> (the exact label is printed by the install command).
launchctl print gui/$(id -u)/com.user.windowless-reaper
After replacing the binary you need to bounce the agent so launchd re-execs the new copy (the running process keeps the old inode):
launchctl kickstart -k gui/$(id -u)/<label>
-
Capture the baseline. The first tick fires the seed snapshot within a few seconds of load; subsequent snapshots fire hourly. Under launchd,
wreaper runinstalls a rotating file sink at~/Library/Logs/windowless-reaper.log— unified logging (log show) is not used.sleep 10 grep "runtime-health" ~/Library/Logs/windowless-reaper.log \ | head -1 > /tmp/wreaper-soak-baseline.log cat /tmp/wreaper-soak-baseline.log
If the file is empty or missing, the agent is not running under launchd (e.g. you started
wreaper runin a shell — that path logs to stderr instead). Confirm withlaunchctl print gui/$(id -u)/com.user.windowless-reaper. -
Force the interesting events. Natural use over 72hrs may not hit dark wake on AC. Schedule explicit cycles so the wake-path counters actually move. Twice a day is enough:
sudo pmset schedule sleep "$(date -v+8H '+%m/%d/%y %H:%M:%S')" sudo pmset schedule wake "$(date -v+8H30M '+%m/%d/%y %H:%M:%S')"
Also close the lid on AC once during the window to exercise dark wake.
-
Capture the final snapshot at 72h. The file sink rotates at ~5 MB, so include the previous segment if it exists.
cat ~/Library/Logs/windowless-reaper.log.1 \ ~/Library/Logs/windowless-reaper.log 2>/dev/null \ > /tmp/wreaper-soak.log grep "runtime-health" /tmp/wreaper-soak.log | tail -1 \ > /tmp/wreaper-soak-final.log launchctl print "gui/$UID/com.user.windowless-reaper" \ | grep -E "last exit|state|runs" \ > /tmp/wreaper-soak-launchctl.log
-
Diff.
diff /tmp/wreaper-soak-baseline.log /tmp/wreaper-soak-final.log cat /tmp/wreaper-soak-launchctl.log
launchctl printshowsruns = 1and nolast exit reason— the daemon never crashed. Any relaunch fails the soak; investigate the exit reason before re-trying.skipped_graceis roughly the number of sleep/wake cycles you forced (one grace tick per wake).skipped_implicit_wakeis low. A handful is normal (kernel wakes during long suspensions); a count comparable toskipped_gracemeansNSWorkspaceis not firing under launchd — that is the bug and the gate must stay up.skipped_not_visible> 0 if you closed the lid on AC — confirms the screen-power gate is observing the agent's session.checkpoint_save_failuresis 0.ticksis consistent with72h / poll_intervalminus the skips.
If all five hold, you have real evidence the daemon behaves under
launchd and the gate can be lifted (remove the banner above and the
stderr warning in wreaper run).
Under launchd, wreaper writes two files in ~/Library/Logs/:
windowless-reaper.log— the main application log. Written in-process by a rotating handler, so it stays size-capped without externallogrotate. This is what you tail day-to-day.windowless-reaper.stderr.log— launchd'sStandardOutPath/StandardErrorPath. Reserved for pre-bootstrap output (dyld errors, Swift fatals); empty in normal operation.
When wreaper run is launched interactively (not via launchd) it skips the
file sink and logs to stderr instead.
tail -f ~/Library/Logs/windowless-reaper.log
launchctl print gui/$UID/com.user.windowless-reaper
wreaper diagnoseSave the file. The watcher picks it up and the engine swaps in the new config
on the next tick. The reload also re-reads log_level unless the launchctl
invocation passed --log-level (the bundled plist does not).
launchctl bootout gui/$UID/com.user.windowless-reaper
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.user.windowless-reaper.plistThe foreground process honours SIGINT / SIGTERM for clean shutdown.
launchd uses SIGTERM on bootout — no force-kill needed.
Symptom: launchctl print shows runs climbing every ~10 s with
last exit code = 64: EX_USAGE. The log file repeats the same
[wreaper] starting … line and nothing after it.
64 is ValidationError from wreaper run. The most common cause is
Accessibility being denied to the binary launchd actually executes
(/usr/local/bin/wreaper) even though wreaper permissions check from
your shell reports granted. That happens when the AX grant is keyed
to Terminal's responsible-process chain rather than to wreaper's
own codesigning identity — interactive shells inherit Terminal's grant;
launchd-spawned processes do not.
The failure path is silent because ValidationError text goes to
stderr, which the LaunchAgent redirects to /dev/null. Only the
pre-check starting … line reaches the file sink.
Recovery:
# 1. confirm the signature identifier is correct
codesign -dv /usr/local/bin/wreaper 2>&1 | grep Identifier
# expect: Identifier=com.user.windowless-reaper
# if not, re-sign with scripts/sign.sh and reinstall
# 2. stop the loop
launchctl bootout gui/$(id -u)/com.user.windowless-reaper 2>/dev/null
# 3. remove any existing "wreaper" entry in
# System Settings → Privacy & Security → Accessibility (use the "−"
# button). tccutil cannot target wreaper — it is a bare CLI binary
# with no bundle identifier, so `tccutil reset Accessibility
# com.user.windowless-reaper` returns OSStatus -10814.
# 4. bootstrap once — this first attempt will still fail, but it
# registers wreaper with TCC under the launchd attribution path so
# the next grant is keyed to the correct identity
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.windowless-reaper.plist
sleep 3
# 5. in Accessibility, click "+", Cmd-Shift-G, enter
# /usr/local/bin/wreaper
# add it, toggle ON.
# 6. restart the agent to pick up the fresh grant
launchctl kickstart -k gui/$(id -u)/com.user.windowless-reaper
sleep 5
launchctl print gui/$(id -u)/com.user.windowless-reaper | grep -E "state|runs|last exit"Healthy state shows state = running, last exit code = 0, and the
log gains lines past starting … (power observers, checkpoint
restored, engine run started, seed runtime-health).
If the crashloop persists with the AX grant correctly placed, run
/usr/local/bin/wreaper run from a shell — that hits the same code
path with stderr attached, and the ValidationError text will print
to the terminal instead of disappearing into /dev/null.
wreaper run # engine loop (also what the LaunchAgent runs)
wreaper check # one tick, print decisions, exit non-zero if any would evict
wreaper clear # politely terminate every allowlisted app that is windowless right now
wreaper status # AX window state per running regular app
wreaper config init [--force]
wreaper config show [--config <path>]
wreaper config validate [path]
wreaper config scaffold [--all-running] [--include-system]
wreaper permissions check
wreaper permissions request # nudge macOS to surface the AX prompt
wreaper permissions path # print the absolute binary path TCC tracks
wreaper install --user [--prefix <path>] [--print-only] [--force]
wreaper uninstall --user [--print-only]
wreaper diagnose # version, AX state, config path, decisions, log tail
Global flags accepted by run, check, clear, status, diagnose:
--config <path>— override the config file path.--log-level <level>—trace|debug|info|notice|warn|error.--dry-run— log decisions but never terminate.
- Allowlist only. No app is ever a candidate unless its bundle ID has a rule.
- Timeout vs cooldown.
timeoutmeasures continuous windowless time before the first termination.cooldownmeasures how long the same bundle stays ignored after a successful termination. - Polite termination. Always
terminate(), neverforceTerminate(). If an app vetoes (unsaved work, modal dialog), the timeout tracker resets and no cooldown starts. - Minimised counts as visible. Minimised windows are user-managed state, not abandoned background work.
- Suspending clock. All timeouts use
SuspendingClock— laptop sleep does not advance them. A post-wake grace tick is skipped to avoid false reaps. - Cooldown after kill. Suppresses immediate re-reaping if the OS or a login item auto-relaunches the app.
macOS does not expose a single reliable "the system was suspended" signal,
so wreaper combines four orthogonal signals — two as a SleepWakeObserver
composite (NSWorkspace sleep/wake + IORegisterForSystemPower) and two
as independent pre-tick gates inside the run loop (NSWorkspace screen
power for dark-wake / display-sleep, and a ContinuousClock-vs-
SuspendingClock drift detector). The pre-tick order is:
isAsleep → isUserVisible → consumeGraceTick → drift (see
ReaperEngine.shouldSkipTick).
Field logs show the kernel-side observers regularly going silent across
both AC dark wakes (Power Nap, maintenance) and — more surprisingly —
battery sleeps, with the drift detector being the only signal that
catches dozens of intermediate kernel wakes during a long suspension.
Correctness is held by the drift backstop (no spurious evictions have
been observed), but dark-wake visibility on AC and battery is
imperfect and the post-wake grace is a single tick, which can be tight
if poll_interval is very short. See
docs/sleep-wake-log-guide.md for log
patterns, two recorded incidents (2026-05-13), and the remediation plan.
CLAUDE.md contains build, test, lint, and pre-commit commands
for contributors (including those using AI coding assistants). Distribution and
release signing live in DISTRIBUTION.md.
MIT — see LICENSE.
