janitor
janitor runs a command in its own process group and tears that group down when
its owner goes away.
It is meant for development stacks where one layer can die without running a
clean shutdown: shells, Claude Code sessions, zmx, Tilt, serve_cmd, local
chains, indexers, dev servers, and similar long-running commands.
Why
Process trees often outlive the thing that started them. If the parent shell,
terminal session, editor agent, or Tilt process exits unexpectedly, child
services can be reparented to launchd, systemd, or another subreaper and
keep ports, files, databases, and CPU alive.
janitor makes that ownership explicit:
- Capture the original parent PID.
- Spawn the command as a new process-group leader.
- Watch the parent, child, signals, and optional worktree path.
- On any death trigger, send
SIGTERM to the child process group.
- Wait for the grace window, then send
SIGKILL if anything remains.
Install
The installer defaults to the latest stable GitHub Release and selects the
archive for the current platform.
curl -fsSL https://raw.githubusercontent.com/alleneubank/janitor/main/install.sh | sh
By default this installs to ~/.local/bin/janitor. Override with PREFIX:
curl -fsSL https://raw.githubusercontent.com/alleneubank/janitor/main/install.sh | PREFIX=/usr/local sh
Install a specific release tag:
curl -fsSL https://raw.githubusercontent.com/alleneubank/janitor/main/install.sh | JANITOR_VERSION=v0.1.0 sh
Source installation remains available for unsupported platforms and maintainer
testing. It requires Zig 0.15.2 or newer:
JANITOR_INSTALL_FROM_SOURCE=1 ./install.sh
Usage
janitor [--watch-path PATH] [--watch-pid PID] [--grace-ms MS] [--poll-ms MS] -- CMD [ARGS...]
janitor version | --version | -V
janitor version (or --version / -V) prints janitor <version> (<sha>),
where <sha> is the short git commit the binary was built from (unknown when
built outside a git checkout), so a deployed binary can be matched to its source.
Examples:
janitor --watch-path "$PWD" -- yarn localnet:up
janitor --watch-path "$PWD" -- bun run src/index.ts daemon
janitor --grace-ms 500 -- tilt up
--watch-path is useful for worktree-based development. If the worktree is
deleted or moved, janitor treats that as a teardown trigger.
--watch-pid watches an arbitrary process by PID. When that process exits,
janitor treats it as a teardown trigger. This is useful when the supervised
command is reparented away from janitor, so watching the owning process
directly is more reliable than relying on janitor's immediate parent.
--poll-ms is accepted for compatibility with earlier development builds. On
supported platforms, the active watcher is event-driven and does not use
periodic idle polling.
Platform Behavior
- macOS and BSD use
kqueue for process, signal, vnode, and timeout waits.
- Linux uses
epoll over pidfd, signalfd, and inotify.
- Process watches, including the original parent, child, and any
--watch-pid
target, use EVFILT_PROC / NOTE_EXIT on macOS/BSD and pidfd on Linux.
- Windows is not supported.
The child command is started in a new process group. Teardown only signals that
group, so unrelated processes are not touched.
Claude Code plugin
The bundled Claude Code plugin wraps the Bash tool so processes a session
starts are drained by janitor when the session ends, including crashes,
/clear, and closed terminals.
It registers a PreToolUse(Bash) hook (janitor cc-hook pretooluse) that
rewrites commands to run under janitor, tied to the session by a per-session
lock (--watch-path) and the Claude session PID (--watch-pid). A SessionEnd
hook drops the lock on clean exits.
The hook fails open: if janitor is missing, unsupported, or errors, commands
run unmodified. The plugin supports macOS and Linux only.
See plugin/ and plugin/README.md for install details and the JANITOR_CC_*
configuration knobs.
Limitations
- A descendant that deliberately calls
setsid() or moves to another process
group can escape. Wrap that daemon with its own janitor if it self-daemonizes.
SIGKILL sent directly to janitor cannot be handled by any userspace
wrapper, so cleanup is impossible in that one case.
- The exit status follows the direct child when available. Signal deaths are
encoded as
128 + signal.
Build And Test
zig build
zig build run -- --help
zig build test
zig build fmt
zig build docs
zig build test includes end-to-end checks that launch real process trees and
verify watched-path, signal, and parent-death cleanup.
Linux can be typechecked from macOS with:
zig build -Dtarget=x86_64-linux
Release Notes For Maintainers