Keeps an Arch machine clean, with your agent doing the deciding.
Finds what is safe to remove on a machine where coding agents leave worktrees, build output, caches and transcripts behind, moves it to a trash you can undo, and proves its work before it touches anything.
- It proves before it removes. A worktree counts as finished only when a merged pull request holds every commit it has, or landed every file its commits changed after a rebase, or when every commit is on the default branch. Anything else waits for approval.
- Agents are the primary user. Every command returns JSON when piped, each exit code names the next step, and the wizard is a thin layer over the same calls.
- Agents can protect their work.
keeper holdoutranks every scanner until it expires, so a session can mark a worktree it will return to. - Nothing is final until purge. Paths move to a trash, and
keeper undorestores them. Local branches are deleted only at purge, and only if they have not moved. - It renices instead of killing. When the machine is overloaded, keeper names the commands holding the CPU and can lower their priority, so the work survives. With the kernel's autogroup on, it lowers each session's autogroup too, never the desktop's; with it off, it lowers the session process the work hangs from, so what it starts next inherits the low priority. The exceptions are processes nobody can reach any more: an orphan whose terminal has closed and that has kept a core busy for half an hour, and an idle one holding memory for hours after whatever started it went away. keeper offers to stop those, by id only.
paru -S keeper-bin # standalone binary
# or: paru -S keeper # Bun source package
# or: npm i -g @cillianb/keeper # same binary from npm, x64 and arm64npx @cillianb/keeper scan runs it once without installing. Install it before keeper timer install, so the schedule
points at a path that stays put.
Install the skill for any agent that reads Agent Skills:
npx skills add cjber/keeperIn Claude Code you can use the plugin instead: /plugin marketplace add cjber/keeper, then /plugin install keeper@keeper.
If keeper itself is missing, the skill asks before installing it. Then give your agent this prompt:
Run keeper status, then keeper scan. Show me what is eligible and what is blocked, and why.
Plan the low-risk items, then ask me before applying anything else.
The skill is at skills/keeper/SKILL.md. The source package also installs it to
/usr/lib/keeper/skills, and the binary package to /usr/share/doc/keeper-bin/skills.
keeper scan Code, no model. Read-only: every finding, with its blockers
|
v
agent review LLM agent, optional. Reads PRs and history, adds keeper hold on unfinished work
|
v
keeper plan Code. Saves the eligible findings you chose
|
v
keeper apply PLAN --yes Code. Rescans, and skips anything that changed since the plan
|
v
trash keeper undo TX restores. keeper purge deletes for good after 14 days
An agent can only add a blocker. It has no way to clear one, so a bad judgement can keep a file but never lose one.
Each finding's id names the thing itself, such as a worktree's path, so it stays the same from one scan to the next.
A plan also records the state that was judged, so approval covers that state and nothing newer. keeper plan ID...
skips an id that is gone or blocked, says why, and plans the rest.
Sizes in scans and plans are uncompressed. On btrfs with compression, removing a directory can free far less than its
size, and worktree build output often stores in under a third of it. Trash sits on the same disk, so nothing is freed
until keeper purge, which reports the measured change in free space as freed.
The tmp category is the exception. Its entries usually live on a tmpfs, so applying it copies them into the trash on
disk, which frees the tmpfs and its inodes at once and keeps undo working until purge. A temp dir often runs out of
inodes before bytes, so each tmp finding also reports inodes. Thousands of entries that differ only in a random
suffix, such as a test suite's mkdtemp leftovers, are one finding, named by their pattern.
tmp looks only at entries you own, directly in $TMPDIR (or /tmp), and in claude-<uid>, where Claude Code
keeps one directory per session. An entry is offered once nothing inside it has changed for a day, and never while a
process has a file or its working directory there, a process listens on a socket there, or, for a Claude Code session,
a process of that session is still running, or a lock file names a process that is still alive. X and session files
(.X<n>-lock, .X11-unix, .ICE-unix, .font-unix, .XIM-unix, .Test-unix) are never offered. Entries over 100M are listed one by one; the rest are grouped by name, with
a blocked row per group that counts what is waiting and why. Holds, claims and keeper's own state are never included.
| Category | Finds | Risk |
|---|---|---|
worktree |
Clean, pushed worktrees and stray directories in a worktree root | low with proof, otherwise medium |
aur |
Clones of uninstalled packages, and build output in the others | low |
pacman-cache |
Old package versions, keeping the newest | low |
orphans |
Dependencies nothing requires | medium |
agent |
Output in configured roots, and paths agents claimed as scratch | low |
build |
Git-ignored target, node_modules, .next, .turbo and .venv with no file written for 3 days |
low |
transcripts |
Claude Code and Codex transcripts over 14 days old | low |
tmp |
Your entries in $TMPDIR or /tmp untouched for a day, with nothing open or listening in them |
low |
package-cache |
Unreferenced uv and pnpm entries, and npm and bun caches over 2G | low |
docker |
Dangling images and build cache unused for 7 days; unused volumes are reported only | low, volumes report only |
logs |
Configured logs over 50M, cut to their last 5M | low |
snapshots |
Timeshift snapshots, which keep deleted data on disk | report only |
apps |
Explicitly installed packages that ship a .desktop launcher and were not seen running for 90 days |
medium, by id only |
large |
Files over 500M under $HOME, stale, outside git repos, dotdirs, downloads, screenshots and application trees |
medium, by id only |
downloads |
Stale downloads, finished installers, and partials (partials over a day old are low) | low or medium, by id only |
screenshots |
Stale images in the Pictures Screenshots and grim/hyprshot folders |
medium, by id only |
duplicates |
Identical files in different personal document trees, keeping the newest copy and offering the rest | medium, by id only |
load |
Commands holding the CPU while the machine is oversubscribed, and orphans busy since their terminal closed | medium, stopping by id only |
memory |
Cgroups at their memory.high cap, memory pressure, and abandoned processes holding RAM and swap |
report only, stopping by id only |
A process counts as abandoned only when every signal agrees: no live terminal on stdin and reparented to init, or to a tmux, screen, zellij or kiln server after its pane closed; no CPU over the one second sample; older than 6 hours; at least 16M of RAM plus swap; and either the script on its command line no longer exists or, unless it daemonised itself, it has written no open file other than its own stdout and stderr for 6 hours. A log its old supervisor redirected output into shows only that the process is still retrying, not work anyone will use. Shells, editors, multiplexer servers, systemd services, anything the desktop autostarted and anything whose working directory or script a hold covers are never offered.
Apps, large files, downloads, screenshots and duplicates are personal or application data, so they are only planned
when you name their id. keeper plan with no ids never takes them, even with --max-risk medium.
large and duplicates skip application trees: anything under a Wine prefix (a directory holding drive_c or
system.reg), a Steam, Lutris, Heroic or GOG library, a ~/Games style library, flatpak data or a VM store, and any
directory holding an install marker such as a .exe, .toc or .pak. A duplicate is offered only when the copy
keeper keeps is in a different top-level folder of your own documents: Documents, Pictures, Music, Videos,
Downloads or Desktop.
These block a worktree whatever its age: uncommitted files, commits on no remote, a running process inside it, an open pull request, a rebase, merge, bisect or cherry-pick in progress, and any hold.
A blocked finding is something keeper found but will not touch yet, because something says the work there may still matter. Each blocker names what to do about it:
| Blocker | To resolve it |
|---|---|
| Touched recently | Wait. It is offered once idle for the age window, 7 days by default, or --stale-days N |
| Built recently | Wait. Build output is offered after 3 days without a build. It counts as build, not as its worktree |
| Uncommitted or untracked files | Commit, stash or delete them |
| Commits on no remote | Push them to keep the work. Once every commit is on the default branch it becomes low risk |
| An open pull request | Merge or close it |
| In use by a running process | Quit what runs there |
| A rebase, merge, bisect or cherry-pick | Finish or abort it |
| Held | keeper release PATH ends the hold early |
| A tool or package operation running | It is offered once that finishes |
keeper never clears a blocker for you, and a blocked path is never removed by hand on its behalf. Press b in the
wizard to list blocked findings, or read blockers in keeper scan.
A full scan takes seconds to minutes on a busy machine, so each category's findings are kept for as long as they stay
useful, and keeper scan --cached or the wizard reuse them:
| Kept for | Categories |
|---|---|
| Never, always scanned | load, memory |
| 15 minutes | worktree |
| 1 hour | build, agent, transcripts, tmp, logs, docker, downloads, screenshots |
| 6 hours | aur, pacman-cache, orphans, package-cache, snapshots, apps, large, duplicates |
Applying rescans the categories in its plan first, so a kept finding can never be what decides a removal, and applying,
undoing or claiming drops the categories it changed. Holds are applied on every read. keeper scan without --cached
always scans everything.
keeper records free space whenever it scans or changes something, at most every 10 minutes, and hourly once the timer
is installed. keeper history returns those readings with the applies, purges, undos and nightly runs, and the
wizard draws them.
The apps category reads a last-seen ledger that keeper sample records hourly: which executables are running, folded
to their owning package. Use is never judged from atime, and only packages with a .desktop launcher count as apps, so
git, ripgrep or docker are never offered. The ledger keeps the last sample in each hour bucket, and coverage counts
only the days that hold a sample. A timer that stopped or a machine that was off therefore leaves a gap, and the window
extends instead of treating the missing days as watched.
A package is proven unused only once the samples cover the whole --app-stale-days window (default 90) and it was not
seen in it. Until then the first run stays quiet: the largest apps not seen yet appear as candidates, still only
removable by their id, so a big app can be dropped before the window fills. Lower --app-stale-days to the time
actually sampled to judge every unseen app against it. With no samples at all, apps reports nothing.
| Command | What it does |
|---|---|
keeper |
Opens the wizard (terminal only) |
keeper status |
Free space, schedule, last run and whether the nightly runs are healthy, holds, trash and missing tools |
keeper scan |
Lists findings and their blockers. --cached reuses recently scanned categories |
keeper plan |
Saves a plan of eligible findings. Apps and personal files need their id. Add --max-risk medium once medium risk is approved |
keeper apply PLAN --yes |
Rescans, then applies what is unchanged |
keeper undo TX |
Restores a transaction from trash |
keeper purge |
Deletes trash over 14 days old for good |
keeper hold PATH --reason R |
Protects a path for 1 day at most; --days N shortens it |
keeper claim PATH |
Declares your own scratch disposable once stale. A git clone is claimable only under ~/.cache/agent-scratch or an agent root |
keeper release PATH |
Drops a hold or claim |
keeper log |
Shows recent mutations and who made them |
keeper history |
Free space readings and the events that changed it, over 30 days or --days N |
keeper sample |
Records free space and which executables are running, as the hourly timer does |
keeper schema |
Describes the CLI as JSON |
keeper auto |
Applies low-risk findings, purges old trash and notifies |
keeper timer install |
Schedules keeper auto daily and keeper sample hourly with systemd, or --cron |
keeper timer remove |
Removes the schedule |
| Exit code | Meaning |
|---|---|
| 0 | Done |
| 1 | Error |
| 2 | Usage error |
| 3 | Needs --yes, which needs someone's approval |
| 4 | The whole plan is stale, so make a new one |
| 5 | Partly done: plan skipped some ids, or apply left some items, so scan again |
| 6 | Another run holds the lock, so retry |
Set KEEPER_ACTOR to name yourself in holds, claims and the log.
keeper runs with no configuration. Everything is optional in ~/.config/keeper/config.toml, and an unknown key is an
error:
# Directories of git checkouts whose ignored build output may be cleaned.
repoRoots = ["~/code"]
# Directories that hold worktrees. Default: ~/.worktrees
worktreeRoots = ["~/.worktrees"]
# AUR helper clones. Default: the paru and yay caches
aurClones = ["~/.cache/paru/clone"]
# Directories that hold only disposable agent output.
agentRoots = ["~/.cache/agent-runs"]
# Regular expressions matched against entry names directly under ~/.cache.
agentCachePatterns = ["^pr\\d+-venv$"]
# Appended logs that nothing rotates.
logs = ["~/scripts/backup.log"]
# Notify below this much free space. Default: 20
warnFreeGb = 20
# Files under $HOME this large or more are offered by `large` once stale, outside application trees. Default: 524288000 (500M)
largeBytes = 524288000
# Run before a worktree is deleted for good, with its trashed path then its original path.
purgeHook = "~/bin/drop-worktree-db"
# Have keeper auto return main checkouts to the default branch and fast-forward them.
freshen = false
# Wizard colours, as #rrggbb or a palette name such as cyan or brightred. Palette names use
# your terminal theme's own colours. Empty is the terminal foreground: reverse video for the
# cursor and dim text for blocked rows. These are the defaults:
[theme]
cursor = ""
cursorText = ""
selected = "green"
blocked = ""
accent = "cyan"
size = ""
category = "blue"
low = "green"
medium = "yellow"
high = "red"If purgeHook exits non-zero, or a transaction's files cannot be removed, that transaction stays in the trash and purge
tries again next time: its id is in failed, its reason in errors, and keeper purge exits 5. freshen never
resets, rebases or stashes, and leaves a checkout alone when tracked files changed or a branch has unpushed commits.
git and pacman-contrib. These add evidence or actions, and keeper status lists any that are missing:
gh for pull request proof, expac to size orphans, notify-send for notifications and
uv to prune its cache. Removing packages and running paccache use sudo -n, so they need passwordless sudo for
those commands.
See CONTRIBUTING.md. Report vulnerabilities as SECURITY.md describes.