A single Go binary that knows what runs on which port, lets you start/stop projects from any stack, gives them a name in the browser (<project>.localhost), and exposes everything over CLI, web dashboard, HTTP API and an MCP server so coding agents can manage your dev servers too.
Built for WSL2 + systemd + Chrome.
- Launch anything with a prefix:
pm run -- npm run dev. pm puts it inside a systemd unit (pm-<project>.service) with thePM_PROJECTenv var (andPM_OWNERwhen it has an owner), so it can attribute the process and kill its whole tree when you stop it. - Detects the real system state with
ss+/proc(cwd, cgroup, cmdline) +docker ps. It even sees what you started outside of pm (it shows up as unmanaged and you adopt it). - Owners by marker: a script that leaves processes running can start them with
PM_OWNER=<name>in the environment. pm shows them with that owner (even unmanaged) andpm down --owner <name>stops them together with everything else the owner has. - Discovers the port on its own: it looks at what the cgroup binds; no need to declare it.
- Collision → next port: if the preferred port is taken, it reassigns to the next free one and injects the port according to the stack (
--portin Vite,-pin Next,PORT=env,0.0.0.0:{port}in Django…). .localhostnames: Caddy on:80routesmyapp.localhost→ the real live port. The config regenerates itself from state.- Tiers:
ephemeral(transient service, gone when it stops) ↔service(persistent user unit, starts at boot). Toggle withpromote/demote. A transient service is whatsystemd-run --usercreates without--scope: systemd itself starts the process aspm-<name>.service, so it survives the terminal, has a real unit status (failed, exit code, OOM reason) and can carry resource limits, and the unit vanishes by itself when the process ends. A scope, by contrast, would only wrap a process that something else already started. - Named compose stacks:
--compose-project shop-qa [--compose-file compose.yml]onpm run --name,pm addandpm edit(compose: {file, project}in the API) starts a stack asdocker compose -f <file> -p <project> ...withCOMPOSE_PROJECT_NAMEset, so two copies of one repo (two worktrees) get their own containers, networks and volumes. Containers are attributed back by theircom.docker.compose.projectlabel, so each shows under its own pm name inpm ps. Docker projects take no limits. - Resource limits:
--mem 2G --cpu 150%caps memory and CPU through systemd (MemoryMax=/CPUQuota=). A memory cap also forbids swap (MemorySwapMax=0), so a project that passes it is killed by the kernel (OOM) instead of being paged out, and its unit endsfailed:pm psshows it as✗ ... failed: oom-kill(the dashboard and/api/v1/psreport statusfailedwith afailurereason) until you start or stop it again.pm add|edit --mem/--cpustore the limits with the project andpm run --mem/--cpuremembers them (with--namethey apply to that process only);pm start --mem/--cpuapplies to that start only and is refused forservice-tier projects (their unit persists: usepm edit, thenpm restartso the unit on disk, which the next boot uses, is rewritten). Docker projects take no limits (the unit only wraps the compose client; setmem_limit/cpusin the compose file). Needs cgroups v2 with thememoryandcpucontrollers delegated to your user manager;pm doctortells you if either is missing.
make install # builds and copies the binary to ~/.local/bin/pm
pm setup # prepares ~/.pm, Caddyfile and systemd units
pm doctor # checks the environment
# install Caddy if missing: sudo apt install -y caddy
systemctl --user enable --now pm-daemon pm-caddy./scripts/install.sh does the same as make install. From Windows: copy scripts/pm.cmd into a folder on your PATH → pm ps, pm ui, etc. forward to WSL.
pm run -- <cmd> launches the cwd (auto-registers, detects port, marks it)
pm run --name <n> -- <cmd> starts a command by name without registering it: --owner, --ports, -e K=V, --log, --mem/--cpu, --compose-project/--compose-file; shows in pm ps with its owner, pm stop forgets it; a run's ports are reserved while its process lives and released when it dies
pm ps list running projects (add -a to include stopped)
pm start|stop|restart <name> start accepts --mem 512M --cpu 150%
pm promote|demote <name> ephemeral ↔ permanent service
pm up|down <group> bring a group up/down
pm down --owner <o> stop everything that belongs to an owner (its projects, runs, and any process with PM_OWNER=<o>) and release its leases and routes; --keep-leases keeps both
pm add|edit|rm <name> registry CRUD
pm claim <port> adopt an 'unmanaged' listener
pm scan discover projects under the roots (PM_ROOTS)
pm listen ports on the machine that pm doesn't manage
pm lease [n] --owner <o> reserve n ports for an owner before starting anything (ls, release <id>)
pm route add <host> <port> publish <name>.localhost for a port pm did not start, optional --owner (ls, rm <host>); an explicit route wins over a project's own name
pm logs <name> [-f] logs (stream with -f)
pm open <name> | pm ui open in the browser
pm serve start the daemon (API + web + SSE + MCP)
pm doctor | pm setup
They answer two different questions and are easy to mix up.
pm scanlooks at your disk. It walks each folder inPM_ROOTSone level deep and, for every directory it recognizes by its files, registers a draft project: stack, launch command, preferred port and how to inject a port (docker-compose.yml→ docker,pubspec.yaml→ Flutter,manage.py→ Django,go.mod→ Go,package.json→ Next/Vite/Nuxt/plain Node, with the package manager picked by lockfile). It never overwrites a project you already registered, and anything you set by hand wins over a rescan.pm runandpm claimreuse the same stack detection (internal/scan).- The detector looks at the live machine. On every refresh it reads
ssfor each listening socket and attributes it to a project through/procanddocker ps, from the most to the least certain signal: thePM_PROJECTmarker in the process environment, thepm-<name>systemd cgroup, docker labels, and finally the process cwd matching a registered project directory. What it cannot attribute is unmanaged (pm listen) until youpm claimit. It also readsPM_OWNERand the compose project label (internal/detector).
In short: scan decides what could run (registry), the detector reports what is running and whose it is (state). Neither starts anything.
If you build a tool that needs ports or processes on a developer machine (a worktree manager, a test harness, a task board), don't allocate ports or call systemd-run yourself: ask pm, which is the single owner of ports on the box. The HTTP API is versioned under /api/v1/ and documented in docs/api/openapi.yaml, and there is an official Go client that imports only the standard library:
go get github.com/antoniojosev/pm/clientctx := context.Background()
c := client.New("http://127.0.0.1:9797")
v, err := c.Version(ctx) // fails if the daemon speaks another API generation
if err != nil {
log.Fatal(err)
}
fmt.Println("pm", v.Version, "docker:", v.Docker)
// Reserve two ports for a worktree before starting anything.
lease, err := c.Leases.Create(ctx, client.LeaseRequest{Owner: "wt-qa", Count: 2})
if err != nil {
log.Fatal(err)
}
// Start a process by specification: no registration needed.
unit, err := c.Run(ctx, client.RunSpec{
Name: "api-qa",
Dir: "/home/me/src/api",
Cmd: []string{"npm", "run", "dev"},
Env: map[string]string{"PORT": fmt.Sprint(lease.Ports[0])},
Limits: client.Limits{Memory: "2G", CPU: "150%"},
Owner: "wt-qa",
Ports: lease.Ports,
})
if err != nil {
log.Fatal(err)
}
fmt.Println("started", unit.Unit, "pid", unit.PID)
// Whose is each port on the machine? PS is `pm ps`: every project, run and
// listener, attributed to its owner where pm can tell.
rows, err := c.PS(ctx)
if err != nil {
log.Fatal(err)
}
for _, r := range rows {
fmt.Println(r.Name, r.Port, r.Status, "owner:", r.Owner)
}
// Give a port a name on port 80 (with Caddy), even if pm did not start it.
if _, err := c.Routes.Add(ctx, client.RouteRequest{Host: "api-qa.localhost", Port: lease.Ports[0], Owner: "wt-qa"}); err != nil {
log.Fatal(err)
}
// Stop everything of the owner, release its leases and remove its routes.
if _, err := c.Owners.Delete(ctx, "wt-qa"); err != nil {
log.Fatal(err)
}The client checks GET /api/v1/version before its first call and returns client.ErrIncompatible if the daemon speaks another API generation. The same operations exist from the shell (pm lease, pm run --name, pm down --owner) and over MCP (pm_run, pm_owner_down). See client/README.md and the runnable client/example_test.go.
http://pm.localhost (or pm ui). Live dashboard (SSE): status, start/stop, promote/demote, streaming logs, adopt unmanaged listeners, groups and full CRUD.
The daemon exposes MCP over HTTP at /mcp. Add it to your .mcp.json:
{ "mcpServers": { "pm": { "type": "http", "url": "http://127.0.0.1:9797/mcp" } } }Tools (mirror of the CLI): pm_list, pm_start, pm_stop, pm_restart, pm_promote, pm_demote, pm_register, pm_remove, pm_claim, pm_scan, pm_logs, pm_group_up, pm_group_down, pm_run (start a process by specification, same body as POST /api/v1/run), pm_owner_down (what pm down --owner does).
| Variable | Default | Meaning |
|---|---|---|
PM_HOME |
~/.pm |
Runtime root: registry, logs, Caddyfile. |
PM_ROOTS |
~/projects |
Folders pm scan walks (one level deep), separated by :. |
PM_DAEMON_URL |
http://127.0.0.1:9797 |
Where the CLI/web look for the daemon. |
PM_REMOTE_SUFFIX |
.pm |
Extra DNS suffix routed by Caddy (e.g. via Tailscale). off disables. |
PM_NO_PROXY |
unset | 1 stops the daemon from managing the Caddyfile (secondary daemons, tests). |
make build # ./pm with the git version stamped in
make test # go test -race ./...
make vet # go vet + gofmt check
make cover # per-package coverageTests run fully isolated: temp PM_HOME, injected live state, and systemctl / systemd-run / docker replaced by fakes, so they never touch a real daemon or ~/.pm.
cmd/pm entrypoint
client/ official Go client for the HTTP API (public, stdlib only)
internal/
config paths and constants
registry persistent catalog (JSON)
detector real state: ss + /proc + docker
scan stack detection / auto-registration
runner systemd units, ports, collision, tiers, groups
proxy generates Caddyfile + reload
daemon HTTP API + SSE + embedded web + proxy reconciliation
api shared service layer (CLI + daemon + MCP)
mcp MCP server (streamable HTTP)
cli cobra commands
webui embedded dashboard (go:embed)
One core, four faces: CLI, Web, HTTP API and MCP all share internal/api.
The web dashboard is a single dependency-free HTML file (internal/webui/dist/index.html) embedded in the binary with go:embed. There is no frontend build step: edit the file, make build, done.
| Tool | What it does | What pm adds |
|---|---|---|
| overmind / foreman | Run the processes of one Procfile in a tmux session / foreground. |
pm is machine-wide: every project on the box, each in its own systemd unit that survives the terminal, plus discovery of processes it did not start. |
| portless | Gives Node dev servers a *.localhost name instead of a port. |
pm names any stack (Node, Django, Go, Flutter, docker-compose…), resolves port collisions by itself, and keeps Caddy in sync from live state rather than from a wrapper. |
| devbox | Reproducible per-project dev environments (Nix) with devbox services. |
pm does not manage toolchains at all; it manages what is running: ports by name, .localhost routing, an MCP server for agents, and systemd services for clean process trees. They compose fine. |
In short, pm is the piece that answers "what is running, on which port, and how do I reach it by name" for a whole machine, and exposes that to humans (CLI/web) and agents (MCP) alike.
MIT © Antonio Vila
