Pitchfork
Pitchfork is a "devilishly good" daemon/process manager for developers, written in Rust by
jdx (author of mise). It starts a background service only if it isn't
already running (no duplicate processes), auto-restarts on failure with backoff, resolves
dependency chains, supports ready checks/file-watching/cron, and can auto start/stop daemons as you
cd in and out of a project via a shell hook. Monitor through a terminal TUI or web dashboard, and
drive it from AI assistants through a built-in MCP server.
- Docs: https://pitchfork.jdx.dev
- Repo: https://github.com/jdx/pitchfork
- CLI reference: https://pitchfork.jdx.dev/cli.html
- Config reference: https://pitchfork.jdx.dev/reference/configuration.html
Tip: run
pitchfork <command> --helpfor the exact, version-specific flags on any subcommand. This skill documents pitchfork ≥ 2.10.
When to reach for pitchfork
- You keep starting the same dev server twice and leaking orphan processes — pitchfork refuses to start a daemon that's already running.
- You want services to come up automatically when you
cdinto a project and shut down when you leave (the shell hook). - You have a stack (db → cache → api → worker) with startup ordering and readiness gating.
- You want pm2/foreman/overmind/systemd ergonomics for local dev without the weight.
Installation
mise use -g pitchfork # Recommended (jdx's own tool)
cargo install pitchfork-cli # From crates.io
brew install pitchfork # Homebrew
# or download a prebuilt binary from https://github.com/jdx/pitchfork/releases
pitchfork --version # Verify
Shell activation (the shell hook — enables auto start/stop)
The shell hook is what makes pitchfork "automatically start daemons when you enter a project
directory and stop them when you leave." It hooks your shell's directory-change so that, on each
cd, pitchfork reconciles the daemons declared in the nearest pitchfork.toml. Add the activation
line to your shell config, then restart your shell:
echo 'eval "$(pitchfork activate bash)"' >> ~/.bashrc # Bash
echo 'eval "$(pitchfork activate zsh)"' >> ~/.zshrc # Zsh
echo 'pitchfork activate fish | source' >> ~/.config/fish/config.fish # Fish
Supported shells: bash, zsh, fish. See https://pitchfork.jdx.dev/guides/shell-hook.html.
How the hook behaves (per the shell-hook guide):
- On entering a directory, daemons whose config includes
auto = ["start"]are started. - On leaving, daemons with
auto = ["stop"]are stopped — but only after a brief delay (so a quickcd out && cd backdoesn't bounce the service), and only once no terminal sessions remain inside the project directory. Open another terminal stillcd'd into the project and the service keeps running.
Tab completions (optional)
pitchfork completion bash > ~/.local/share/bash-completion/completions/pitchfork
pitchfork completion zsh > ~/.zfunc/_pitchfork
pitchfork completion fish > ~/.config/fish/completions/pitchfork.fish
Start at boot (optional)
boot registers the supervisor with the OS init system (launchd on macOS, systemd on Linux), so
daemons marked boot_start = true come up at login/startup.
pitchfork boot enable # User-level (~/Library/LaunchAgents or ~/.config/systemd/user)
sudo pitchfork boot enable # System-level, for all users (/Library/LaunchDaemons or /etc/systemd/system)
pitchfork boot status
pitchfork boot disable
Core commands
pitchfork run <ID> -- <CMD>... # Run an ad-hoc command in the background under name ID (no toml needed)
pitchfork start [ID...] # Start daemon(s) defined in pitchfork.toml; -l/-g/-a for local/global/all
pitchfork stop [ID...] # Stop daemon(s); -a/-l/-g; graceful SIGTERM then SIGKILL
pitchfork restart <ID> # Stop then start a daemon
pitchfork status <ID> # Detailed status of one daemon (PID, status)
pitchfork list # Table of all daemons + state (alias: ls; --hide-header to omit header)
pitchfork logs [ID...] # Show/tail logs; omit ID for all daemons
pitchfork wait <ID> # Block until the daemon STOPS, tailing its logs (exits with its code)
pitchfork enable <ID> # Allow a daemon to start (undo disable)
pitchfork disable <ID> # Prevent a daemon from starting/restarting
pitchfork clean # Drop stopped/failed entries from the list (alias: c)
pitchfork tui # Interactive terminal dashboard
Most commands have short aliases: r run, s start, kill stop, ls list, l logs, w wait,
e enable, d disable, c clean, stat status, cfg config, sup supervisor.
run — one-off background daemons (no config file)
run is the fastest way to background a command. The name (<ID>) makes it idempotent and
addressable by every other command.
pitchfork run web -- npm run dev # Background a dev server named "web"
pitchfork run api -- ./server --port 8080
pitchfork run api -f -- ./server # -f/--force: restart if "api" already running
pitchfork run api --retry 3 -- ./server # Restart up to 3 times on failure
pitchfork run api -d 5 -- ./server # Consider ready after a 5s delay (default 3s)
pitchfork run api -o 'Listening on' -- ./server # Ready when stdout matches this regex
pitchfork run api --http http://localhost:8080/health -- ./server # Ready on HTTP 2xx
pitchfork run api --port 8080 -- ./server # Ready when TCP port 8080 is listening
pitchfork run api --cmd 'pg_isready' -- ./server # Ready when this shell command exits 0
pitchfork run web --expected-port 3000 --bump -- npm run dev # Find a free port if 3000 is taken
pitchfork run build -q -- ./build.sh # -q/--quiet: suppress startup log output
Everything after
--is the command and its args, run verbatim — no shell parsing surprises.
start / stop — daemons from pitchfork.toml
pitchfork start api # Start one daemon defined in pitchfork.toml
pitchfork start api worker # Start several
pitchfork start -l # All LOCAL daemons in ./pitchfork.toml (alias --all-local)
pitchfork start -g # All GLOBAL daemons in ~/.config/pitchfork/config.toml (--all-global)
pitchfork start -a # All daemons, local and global
pitchfork start api -f # Force restart if already running
pitchfork start api --port 8080 # Override/add a ready check at start time
pitchfork stop -a # Stop everything in reverse dependency order
start waits for each daemon's ready check to pass before returning. stop does a graceful
shutdown: SIGTERM, wait ~3s (fast polling), then SIGKILL if still alive. With -a/-l/-g, daemons
stop in reverse dependency order (dependents before their dependencies).
logs — view and follow output
pitchfork logs api # All logs for "api" (paged if long)
pitchfork logs api worker # Multiple daemons
pitchfork logs # All daemons
pitchfork logs api -n 50 # Last 50 lines
pitchfork logs api -f # Follow in real time (alias --tail/--follow)
pitchfork logs api --since 5min # Relative window: 5min, 2h, 1d
pitchfork logs api --since '10:30' --until '12:00' # Time window (today)
pitchfork logs api --since '2024-01-15 10:00:00' # Absolute datetime
pitchfork logs api --raw -n 100 # Raw lines, no color/formatting (good for piping)
pitchfork logs api --clear # Delete logs for "api" (pitchfork logs --clear = all)
Logs are stored under the pitchfork state dir (e.g. ~/.local/state/pitchfork/logs).
wait — block until a daemon stops
wait blocks until the daemon exits, streaming its logs, and returns the daemon's exit code.
Use it to chain on a one-shot daemon or keep a script attached to a long-running one:
pitchfork wait migrate && echo "migration done"
Note:
waitwaits for the daemon to stop. To block until a service is ready and then continue, use a ready check (--port,--http,--output,--cmd,--delay) onrun/start, which already blocks until ready before returning.
Configuration: pitchfork.toml
Define daemons in a pitchfork.toml at your project root. Each [daemons.<id>] block describes one
service. A config.toml in ~/.config/pitchfork/ (or /etc/pitchfork/) holds global daemons
available everywhere.
# Optional: prefix this file's daemon names, e.g. referenced as "frontend/web"
namespace = "frontend"
[daemons.postgres]
run = "postgres -D ./data"
auto = ["start", "stop"] # auto-start on cd-in, auto-stop on cd-out (needs the shell hook)
ready_port = 5432 # ready when this TCP port accepts connections
dir = "db" # working directory (relative to the toml, or absolute)
[daemons.redis]
run = "redis-server"
auto = ["start", "stop"]
ready_output = "Ready to accept connections" # ready when stdout matches this regex
[daemons.api]
run = "npm run dev:api"
depends = ["postgres", "redis"] # started after deps are ready (topological; independents in parallel)
ready_http = "http://localhost:8080/health" # ready when this URL returns 2xx
retry = 3 # auto-restart up to 3 times on failure (true = unlimited)
watch = ["src/**/*.ts"] # restart when matching files change
env = { NODE_ENV = "development", PORT = "8080" }
[daemons.worker]
run = "npm run worker"
depends = ["redis"]
ready_delay = 2 # just wait N seconds, then consider ready
[daemons.cleanup]
run = "./scripts/cleanup.sh"
cron = "0 0 2 * * *" # run on a 6-field cron schedule (sec min hour dom mon dow): 2am daily
Daemon keys
| Key | Type | Purpose |
|---|---|---|
run |
string (required) | The command to execute. |
dir |
string | Working directory (relative to the toml, or absolute). |
env |
table | Environment variables, e.g. { NODE_ENV = "development" }. |
user |
string | Run as this user/uid (e.g. "postgres" or "501"); needs privileges. |
auto |
array | Shell-hook lifecycle: "start" (on cd-in), "stop" (on cd-out). |
depends |
array | Daemons that must be ready first; topological, independents run in parallel. Cross-namespace via "global/postgres". |
retry |
int or bool | Auto-restart on failure: a count, or true for unlimited (with backoff). |
boot_start |
bool | Start this daemon when the supervisor starts at boot. |
mise |
bool | Run the command through mise (load mise.toml tools/env first). |
| Ready checks | (pick one; all block start/run until satisfied) |
|
ready_delay |
int | Seconds to wait, then mark ready. |
ready_output |
string | Regex; ready when daemon output matches. |
ready_http |
string or table | URL that must return 2xx, or { url = "...", status = [200, 401] }. |
ready_port |
int | TCP port that must be listening. |
ready_cmd |
string | Shell command polled until it exits 0. |
| File watching | ||
watch |
array | Glob patterns; matching changes trigger a restart. |
watch_mode |
string | "native", "poll", or "auto" (default). |
| Ports / proxy | ||
port |
int / array / table | Expected port(s); table form { expect = [3000], bump = 10 } auto-finds a free port. |
| Scheduling | ||
cron |
string or table | 6-field cron (sec min hour dom mon dow), or { schedule, retrigger, immediate }. |
| Resource limits | ||
memory_limit |
string | e.g. "512MB", "2GiB" — restart/kill if exceeded. |
cpu_limit |
number | CPU percent cap (100 = one core; 200 = two). |
stop_signal |
string or table | Signal for graceful stop, e.g. "SIGINT" or { signal = "SIGINT", timeout = "5s" }. |
Deprecated port keys you may see in older configs:
expected_port,auto_bump_port,port_bump_attempts. Prefer the unifiedportkey.
Lifecycle hooks — [daemons.<id>.hooks]
Run commands at lifecycle transitions (see https://pitchfork.jdx.dev/guides/lifecycle-hooks.html):
[daemons.api]
run = "npm run dev:api"
ready_http = "http://localhost:8080/health"
[daemons.api.hooks] -X POST https://alerts.example.com/ready" # daemon passed its ready check # daemon failed 'retrying...'" # before each retry attempt # explicitly stopped by pitchfork # any termination (clean exit, crash, or stop)
# on_output supports filtering: { run = "...", regex = "ERROR", debounce = "2s" }
Cron scheduling
cron uses a 6-field expression: second minute hour day-of-month month day-of-week
(Sunday = 0). The inline-table form adds a retrigger policy:
[daemons.report]
run = "./generate-report.sh"
cron = { schedule = "0 0 2 * * *", retrigger = "finish", immediate = false }
retrigger controls what happens when the schedule fires while the previous run is still active:
| Value | Behavior |
|---|---|
finish (default) |
Only retrigger once the previous run has finished — no overlap. |
always |
Kill the running instance and start fresh — always run the latest. |
success |
Only retrigger if the previous run exited 0. |
fail |
Only retrigger if the previous run failed (auto-retry logic). |
See https://pitchfork.jdx.dev/guides/scheduling.html.
Groups
Group daemons to start/stop several at once:
[groups.backend]
daemons = ["postgres", "redis", "api", "worker"]
pitchfork start backend # start the whole group
Managing config from the CLI
config add writes a [daemons.<id>] block into the nearest pitchfork.toml:
pitchfork config # List all pitchfork.toml files from the cwd upward
pitchfork config add api -- npm run dev # Command after --
pitchfork config add api --run 'npm start' --retry 3
pitchfork config add api --run 'npm start' --watch 'src/**/*.ts'
pitchfork config add api --run 'npm start' --autostart --autostop # sets auto = ["start","stop"]
pitchfork config add worker --run './worker' --depends api
pitchfork config add report --run './report.sh' \
--cron-schedule '0 0 2 * * *' --cron-retrigger finish
pitchfork config add api --run './server' \
--ready-http http://localhost:8080/health # also: --ready-output, --port, --ready-cmd, --delay
pitchfork config add api --run './server' --on-ready 'curl .../ready' --on-fail './cleanup.sh'
pitchfork config remove api # Remove a daemon (alias: rm)
Namespaced ids are supported, e.g. pitchfork config add frontend/api ....
Auto start/stop (the day-to-day workflow)
With the shell hook installed (pitchfork activate), mark daemons auto = ["start", "stop"].
Entering the project directory starts them; leaving stops them (after a short delay, once no
terminals remain in the dir). Services come up exactly when you're working and clean up when you
move on — no manual start/stop needed.
[daemons.web]
run = "npm run dev"
auto = ["start", "stop"]
ready_port = 3000
Use auto = ["start"] alone to start on entry but leave it running, or auto = ["stop"] to only
clean up on exit.
Reverse proxy — stable *.localhost URLs
The proxy maps a stable slug URL (e.g. https://myapp.localhost) to a daemon's actual port, so
bookmarks and configs don't break when a port changes or gets bumped. Slugs live in the global
config under [slugs].
pitchfork proxy trust # Install the proxy's self-signed TLS cert into the system trust store
pitchfork proxy add ... # Map a slug → project dir + daemon
pitchfork proxy status # Show all slugs and their state
pitchfork proxy remove ... # (alias: rm)
Enable it in config:
[settings.proxy]
enable = true
TUI dashboard
pitchfork tui # Interactive terminal dashboard (start/stop/restart, view logs, vim-style keys)
pitchfork list gives a quick text overview; pitchfork logs -f follows output. A browser-based
web dashboard is also available — see https://pitchfork.jdx.dev/guides/tui.html.
Supervisor
A background supervisor process tracks daemons and performs restarts/scheduling/file-watching. The
shell hook and boot enable manage it for you, but you can drive it directly:
pitchfork supervisor start # Start the supervisor in the background
pitchfork supervisor status
pitchfork supervisor stop
pitchfork supervisor run # Run it in the foreground (debugging)
Container mode
For Docker/CI, pitchfork can run as PID 1 / foreground supervisor that brings up the whole stack and keeps the container alive. See https://pitchfork.jdx.dev/guides/container-mode.html.
MCP server (AI assistants)
Pitchfork ships an MCP server so assistants like Claude Code and Cursor can manage daemons directly:
pitchfork mcp # Speaks MCP over stdin/stdout; wire into your assistant's MCP config
Example Claude Code registration:
claude mcp add pitchfork -- pitchfork mcp
Exposed tools: pitchfork_status (list daemons + PID/status/errors), pitchfork_start
(with force to restart), pitchfork_stop, pitchfork_restart, and pitchfork_logs
(n lines, optional daemon ids). See https://pitchfork.jdx.dev/guides/mcp.html.
Quick recipes
Background a dev server and follow it:
pitchfork run web -- npm run dev
pitchfork logs web -f
A full stack with ordering and readiness, auto-managed on cd:
[daemons.db]
run = "docker compose up postgres"
auto = ["start", "stop"]
ready_port = 5432
[daemons.api]
run = "npm run dev:api"
depends = ["db"]
auto = ["start", "stop"]
ready_http = "http://localhost:8080/health"
retry = 3
watch = ["src/**/*.ts"]
[daemons.web]
run = "npm run dev:web"
depends = ["api"]
auto = ["start", "stop"]
ready_port = 3000
Then just cd into the project (with the shell hook active) and everything comes up in order;
cd away and it tears down.
Tips & gotchas
- Pitchfork won't start a daemon that's already running — re-running
start/runis safe and idempotent (it's how you avoid duplicate dev servers). - Prefer
depends+ aready_*check over manual ordering orsleep— pitchfork blocks on readiness and parallelizes independent daemons. ready_*blocks until ready;waitblocks until stopped. Don't confuse them.- The shell hook only stops a daemon once no terminal sessions remain in the project dir — a second terminal still in the directory keeps services alive.
- Cron is 6 fields (leading seconds), unlike classic 5-field crontab.
pitchfork cleanclears stale stopped/failed rows fromlist; it never touches running daemons or your config.- Global daemons live in
~/.config/pitchfork/config.toml; reference them across projects asglobal/<name>(or your configured namespace) independs/groups. - Run
pitchfork <command> --helpfor exact, version-specific flags.