Worktree reaper
The second optional Herdr plugin, in herdr-reaper/ (id wayne930242.straw-boss-reaper), closes the panes agents leave behind in worktree workspaces and removes the checkouts that are clean. It is plain JavaScript that imports nothing from src/ and talks to Herdr through the herdr CLI (HERDR_BIN_PATH) and to git directly.
A sweep decides in this order; the sections below give the details:
flowchart TD
w["Workspace in a linked worktree"] --> pin{"Pinned?"}
pin -->|"yes"| keep["Never touched"]
pin -->|"no"| pane{"A pane that ran an agent is now<br/>an idle shell, not focused, for 60 s?"}
pane -->|"no"| left["Left alone"]
pane -->|"yes"| close["Close the pane"]
close --> last{"Every pane closed?"}
last -->|"yes"| co{"Checkout clean, and nothing holds<br/>or uses it?"}
co -->|"yes"| rm["Remove the checkout<br/>(the branch stays)"]
co -->|"no"| kept["Keep it and retry next sweep"]
Only a workspace whose worktree is a linked git worktree (worktree.is_linked_worktree) is ever touched, whatever its label. A workspace of the main checkout, or of no repository, is never touched, and neither is anything a pinned workspace holds; the one exception is a main-checkout shell workspace a worktree was grouped under (see the parent shell below). An optional config.json in the plugin’s config directory (herdr plugin config-dir wayne930242.straw-boss-reaper) with { "only_under": ["/path"] } narrows the scope further, to repositories and checkouts below the listed directories; { "retain_idle_hours": 72 } sets how long a pi-herdr-agents parent session that is open in no Herdr pane may stay silent before its worktrees are released (see the checkout below; a value that is not a positive number means the default).
What closes a pane
Section titled “What closes a pane”The plugin remembers which panes ran an agent: a pane.agent_detected event and every sweep that sees agent set record the pane’s terminal. A pane that was recorded, now has no agent, and whose shell is idle (Herdr’s pane process-info shows the shell as the only foreground process) is a candidate. The first sweep after the plugin’s state is created counts every agent-less pane in a linked-worktree workspace as recorded, so leftovers from before the plugin was installed go too, and it starts as soon as the plugin is installed (the first hook starts the ticker, which waits 20 s, and a candidate waits 60 s more: about a minute and a half).
Details
There is no log-only first run: reaping begins at once, so run dry-run and pin first. Only a pane the first sweep treats as a leftover is recorded; a pane it skipped because it was pinned, focused or running something stays the user’s shell for later sweeps too. Panes opened afterwards that never ran an agent are the user’s shells and stay. A candidate must stay one for CONFIRM_MS (60 s) before it is closed, so an agent restarting in the same pane, or a command started at the prompt, is not caught between two looks; the reap-now action skips the wait.
The focused pane is never closed, and a pane whose shell runs something (a dev server, an editor) is skipped with that process named. Right before each close the plugin looks at the pane again: it must still be the same terminal (a pane id can be reused), have no agent, not be focused, have an idle shell, and its workspace must not be pinned (token and state file, read fresh). Anything that changed since the plan skips the close and keeps the workspace’s checkout for that run.
“Focused” is the one pane Herdr reports as focused; a second client (the web UI, say) that is viewing another pane does not count, so a pane someone watches from there can be closed. Closing a workspace’s last pane lets Herdr close its tab and then the workspace, with no dialog.
The checkout
Section titled “The checkout”When every pane of a workspace was closed by one sweep, the plugin waits up to 5 s for Herdr to close the workspace, then reads git --no-optional-locks status --porcelain --untracked-files=all --ignore-submodules=none in the checkout (the options keep a repository or user setting such as status.showUntrackedFiles=no from hiding files). It also keeps the checkout when git ls-files -v shows skip-worktree or assume-unchanged files, whose edits status cannot show.
Details
Right before removing, it lists Herdr’s panes and workspaces again and keeps the checkout if any live pane’s cwd or foreground cwd is the checkout or below it, or any live workspace has the same checkout path: in the usual pi-herdr-agents layout the worker’s pane sits in the parent’s workspace, not in the linked worktree’s, and it must not lose its directory. A list that cannot be read keeps the checkout too. This applies to checkouts retried from the pending list as well. Three more guards decide, in this order, whether a clean checkout is released.
Each keeps it as pending (retried every sweep, with no give-up) and logs keep checkout <path>: <reason>, branch kept, in a dry run as would keep:
-
pi-herdr-agents holds it. pi-herdr-agents never removes a worktree on its own: the parent session does, with
worktree_remove, which marks the child’s manifestremoved. The manifests are<session dir>/artifacts/<parent session id>/worktree-runs/<child id>.json; they holdpath,state,name,updatedAtand the child’ssessionFile. The reaper reads them below every directory pi may keep its sessions in:<pi agent dir>/sessions, with one directory per working directory below it (PI_CODING_AGENT_DIR, default~/.pi/agent);PI_CODING_AGENT_SESSION_DIR; thesessionDirsetting of the user (<pi agent dir>/settings.json) and of the repository (<repository>/.pi/settings.json, a relative path counted from the repository). WithsessionDirset, pi putsartifacts/straight into that directory, and the reaper reads that layout too.A directory that does not exist is logged once per run (
pi sessions directory <path> does not exist) and holds nothing; a settings file that cannot be parsed keeps the checkout. The reaper cannot see pi’s--session-diroption or a variable that only a shell sets: Herdr’s own environment must carryPI_CODING_AGENT_DIRandPI_CODING_AGENT_SESSION_DIR, or pi’s settings must name the directory. A manifest names the checkout when itspathis the same directory as the checkout’s, compared by canonical path: a symlinked prefix (/tmpand/private/tmp), a trailing slash or..segment, and the letter case on a case-insensitive file system make no difference.A manifest that names the checkout and is not
removed(provisioning,provisioned,running,ready_for_review,needs_help,failed,cancelled) keeps it while its parent is alive, which two things show. The first is a Herdr pane that runs pi on the parent session (agentispiand its session file is<time>_<parent session id>.jsonl, read fromherdr pane list), however long the parent has been idle. The second is activity: the newest modification time of the manifest, itsupdatedAt, the parent session’s.jsonland the child’s session file withinretain_idle_hours(default 72) of now. When Herdr’s pane list cannot be read, the activity rule alone decides.A parent that is open in no pane and wrote nothing for longer has ended, and the checkout is then the reaper’s. A newer
removedmanifest on the same path supersedes an older run. A manifest that names the path but cannot be parsed (it is matched by the checkout’s directory name, in any letter case), a sessions directory that cannot be read, or a reaper wired without the reader keeps the checkout (fail closed). Closing the idle root shell of such a workspace is unchanged, but the workspace closing with it no longer leads to the removal. -
Another pane or workspace uses it (the check above).
-
A process uses it. The plugin lists every process of the user (
lsof -d cwdon macOS,/proc/<pid>/cwdon Linux), not only Herdr’s panes, and keeps the checkout when one has its cwd at or below it (an editor, a dev server, a pi that was started there, a terminal outside Herdr). In a dry run the idle shells of the panes it would close are ignored. A process list that cannot be had keeps the checkout; processes of other users are not inspected, and neither are the user’s own processes Linux will not let it inspect (non-dumpable ones such as ssh-agent or gpg-agent), which rarely sit in a worktree.The manifests of guard 1 and this process list are each read once and reused for 10 s (a sweep over many pending checkouts then costs one read of each, not one per checkout), and a sweep that has just closed a pane reads the process list again, so the shell that exited does not hold its own checkout. A clean checkout is removed with
git worktree remove, never forced; any change, tracked or not, keeps it, and so does a detached HEAD that no branch, remote branch or tag contains, because the checkout would be the last place those commits live.Git-ignored files do not count as changes, so
.envfiles,node_modules, caches and local databases in a clean checkout are deleted with it; keep a checkout whose ignored files matter by pinning its workspace. The branch is never touched. A failed removal logs git’s ownfatal:/error:line, not node’s “Command failed” header. Git refuses a checkout with a submodule by design (“working trees containing submodules cannot be moved or removed”), so the plugin clears the way itself, and only when nothing is lost: every initialized submodule (recursively) must have no uncommitted or untracked paths (ignored files are fine) and its HEAD must be contained in a remote-tracking ref of its own repository or in a branch, tag or remote-tracking ref of the main repository’s own copy (.git/modules/...), and no local branch may hold commits outside the remote-tracking refs.The submodule repositories of a linked worktree live in
.git/worktrees/<id>/modules/...and are deleted with it, which is why those refs count. Then the plugin runsgit submodule deinit --all(no--force), deletes thatmodulesdirectory (git still refuses the removal while it exists), and runs the plaingit worktree remove. A failed check keeps the checkout and logs the submodule and why, for examplekeep checkout <path>: submodule community has 2 uncommitted paths, branch keptorsubmodule community HEAD abc1234 is in no ref outside this worktree (no remote-tracking ref contains it); a removal logs(clean; 1 submodule deinitialized first; branch X kept). The main checkout and its submodules are never touched.After a failed or timed-out removal the plugin checks whether the checkout is half-removed (the directory is still there but git no longer registers it as a worktree of the repository, or its
.gitis gone, as when git deleted them and then failed on the files, say through a self-referencingnode_modules). If so the log says that leftover files remain at the path, git no longer tracks it and the branch is kept, and that the user can delete the directory; the plugin never deletes or moves the leftover itself. A workspace that is still open after the wait stays pending in the state file and is retried by the next sweeps for an hour.Each decision is a line in the plugin log with its reason (
close,remove checkout,keep checkout ... uncommitted changes,keep checkout ... pi-herdr-agents child ...,skip ... workspace is pinned).
The parent shell
Section titled “The parent shell”When a linked-worktree workspace is open, Herdr groups it under the workspace of the repository’s main checkout and opens a plain shell workspace for that checkout if none exists. The plugin remembers a non-linked workspace as a parent once a linked worktree workspace with the same worktree.repo_key (else repo_root) was seen open, in a sweep or by the workspace.created hook, within only_under; a workspace never seen with a worktree under it is never remembered, so it is never closed.
Details
After the checkout step of a sweep, on a fresh look at Herdr, a remembered parent is closed (herdr workspace close, never the group form, so Herdr refuses if a worktree workspace appeared meanwhile) when no linked workspace of its repository is left and every pane has no agent and an idle shell, none is focused, the workspace is not pinned, and its name is the one Herdr gave it (the repository or checkout directory name) and was never renamed (a rename also pins it).
A workspace a Straw Boss tower stands in is never closed, whatever its panes look like: the sb server puts the tower’s tab in this very workspace, and between runs the standing pane is a shell that waits at a prompt, which is exactly what the rule above closes. The plugin reads the tokens the server writes and nothing else of Straw Boss: the workspace token tower, and the pane token sb_role with the value tower (the standing pane) or ally (an ally run’s pane, split off the tower’s tab).
Any one of them keeps the parent, and the parent is not tracked as a candidate meanwhile; a token that is gone (the tower was closed, or the tokens’ 10 minute lifetime ran out with no server alive to renew them) leaves the parent to the rule above again. It must qualify for CONFIRM_MS (60 s) first, tracked in parentCandidates; closing the workspace closes only its panes, and the main checkout and every branch stay.
The log lines are wait parent <label> (workspace <id>): ..., skip parent ...: <reason> (worktree workspace still open, pinned, name changed, a tower stands in it, agent or running process in a pane, focused pane), and close parent workspace ...; a dry run prints would close parent workspace ..., counting the worktree workspaces it would close as gone. A child closed by the user rather than the plugin is handled the same way, by the next sweep (up to 10 minutes later) and the confirmation wait.
The pin action is one toggle, in the workspace context: it sets or clears the workspace token pin, a Nerd Font pin (nf-md-pin, U+F0403), through workspace.report_metadata under the source wayne930242.straw-boss-reaper. Herdr shows a workspace token only where a row asks for it, so add { token = "$pin" } to [ui.sidebar.spaces] rows as the README shows. Bind it with [[keys.command]] (prefix+i in the README’s example: prefix+P is Herdr’s default rename_pane, and alt combinations are often taken by window managers such as AeroSpace); a key invokes it in the focused workspace. A pin is also written to the plugin’s state file, because Herdr drops workspace tokens when its server restarts, and a sweep puts the token back.
Details
A linked worktree’s pin is keyed by its checkout path, so it outlives the workspace and the checkout is never removed; any other workspace’s pin is keyed by its id (workspace:<id>) and goes when the workspace closes, so a later workspace on the same main checkout starts unpinned. A sweep moves a main-checkout pin an older version keyed by its path to the ids of the workspaces open on it. A workspace is pinned when either the token or the state entry says so, and a pinned workspace is skipped by every sweep and its checkout is never removed.
A pin does nothing else: closing a pinned workspace closes it, and a linked worktree’s pin keeps its checkout from being removed until the checkout is gone or you unpin a workspace on it.
Pinning by renaming
Section titled “Pinning by renaming”Herdr’s workspace.renamed event does not say who renamed, but nothing renames a workspace except the user: pi-herdr-agents and Straw Boss set the label when they create the workspace, and herdr workspace create --label and herdr worktree create --label fire no rename. herdr worktree open --label on a worktree whose workspace is already open does fire one, and counts as a rename. So every rename pins the workspace (any workspace, linked worktree or not, within only_under) and marks its record renamed, even when a sweep recorded the new label first; a workspace already pinned stays as it is. Unpinning clears the token and the state entry and leaves the name alone.
Details
Herdr reuses the ids of closed workspaces, so a workspace.created event replaces any earlier record of its id, the workspace.closed event (or a pane.exited that took the workspace’s last pane) deletes the record and any workspace:<id> pin of the id, and a sweep drops the records of the workspaces it closed. Each record also stores the workspace’s checkout path, and a record whose checkout differs from the live workspace’s belongs to an earlier workspace on a reused id and is ignored.
When it runs
Section titled “When it runs”A sweep runs every 10 minutes and again when an agent was released, CONFIRM_MS later. Herdr has no timers for plugins, so the startup hook and the event hooks start a ticker, a small detached node process that checks the state file every 15 s and runs the sweep action through herdr plugin action invoke when one is due, which makes every sweep a normal, logged plugin command. This departs from Herdr’s guidance (0.9.5): startup hooks are meant to be one-shot rather than supervised daemons, and plugins are told to stop detached background processes before their command exits.
Details
The ticker is the only way a plugin can sweep on a schedule, and it limits the departure: it is a single process (a beat file and a lock), it keeps nothing but the beat and its log (ticker.log in the state directory, truncated at a start when over 200 KB), and it ends on its own after three failed invocations in a row. After an uninstall, unlink or server exit it therefore lives until its next sweep is due, up to 10 minutes, plus about a minute for the three retries: roughly 11 minutes.
The state directory belongs to the plugin id, so two Herdr servers on one machine (a named session, say) would share it: one ticker for both, and workspace and pane ids of the two servers colliding in the state file. Use the plugin with one server. A ticker beats in ticker.json; a hook starts a new one only when the beat is older than 45 s, and two started together settle it with a lock. The ticker ends after three failed invocations in a row, which happens when the Herdr server is gone or the plugin was unlinked; the next hook of a new server starts another.
The event hooks do bookkeeping only (workspace.created, workspace.renamed, workspace.closed, pane.exited, pane.agent_detected), and none is hooked to pane.updated, so no per-output work exists. The pane.agent_detected event for a release marks the pane a candidate from that moment and schedules the sweep for the end of its wait; the other events cost one herdr call and a state write. Sweeps never overlap (a lock whose holder keeps touching it, so only a dead run’s lock goes stale after 10 minutes, and one waiter at a time takes a stale lock over; a second sweep logs that it was skipped).
Every herdr call has a 30 s timeout and every git call 60 s (5 minutes to remove a checkout), so a hung call fails instead of holding the lock. bash herdr-reaper/run.sh reaper.mjs dry-run, run in a terminal inside Herdr, prints what a sweep would do now without changing anything, the state file included; it reads the plugin’s state, so its first-sweep rule applies until the plugin has swept once.
Limits
Section titled “Limits”The state is state.json in the plugin’s state directory. Only a missing file reads as empty; a damaged or unreadable one makes the sweep and the events fail with the file’s path in the plugin log (and stops the ticker) instead of bringing back the first-sweep rule and forgetting the pins, so repair or remove the file. A user’s shell that outlived its agent in a linked-worktree workspace is closed like a leftover: pin the workspace to keep it. Ignored files (.env*, node_modules, build output, local databases) do not make a checkout dirty, so they go with it; pin the workspace to keep them.
Details
A pi-herdr-agents worktree is held by guard 1 whatever it contains; the ignored-file loss is for checkouts nothing holds. pi-herdr-agents keeps no “parent is alive” record, so the hold on its worktrees rests on two signals: a pane in which Herdr sees pi on the parent session, and the idle limit. A parent session that was resumed, forked or restarted with /new in its pane is another session id, so its old id no longer counts as open; a parent in a terminal outside Herdr is held only by the idle limit. A pi session of either kind left idle past retain_idle_hours and resumed later may find its clean worktree removed (the branch is kept).
The agent event compares its terminal with the pane’s only when the payload names one (Herdr 0.9.5 documents pane_id and workspace_id), so a pane id reused within the hook’s latency can still be misread there; the sweep’s checks use the terminal id. A Herdr launched from a macOS app has a short PATH; run.sh finds node the way the net-view plugin’s does. The plugin needs Herdr 0.9.3 or newer, git, node 18 or newer, and a Unix shell, so it runs on macOS and Linux only. tests/reaper.test.ts drives the decisions and the sweeps against a fake Herdr and git.