All projects
Open source · v0.1.0Project 08
DevToolsCLI + MCPOpen source

pm

pm run -- npm run dev puts the process in a systemd unit, discovers the port it opened on its own, resolves collisions and publishes project.localhost via Caddy. One core, four faces: CLI, web, HTTP API and 13 MCP tools. Built for WSL2 + systemd + Chrome.

GoCobrasystemdCaddyMCPSSEMIT

Everyone wants port 3000. pm gives each project a name and handles the port itself.

A Go binary that knows what runs on which port, starts and stops projects of any stack and publishes them as project.localhost. People use it through the CLI and the web, agents through MCP, all with the same verbs.

Four terminals · without pm
tienda Next:3000
landing Next:3000 taken
panel Vite:5173
docs Vite:5173 taken
Two start. Two fail or drift to a port nobody wrote down.
Four names · with pm
tienda.localhost→ :3000
landing.localhost→ :3001
panel.localhost→ :5173
docs.localhost→ :5174
Collision → next free port, injected per stack. The port becomes a detail.
Example using the preferred ports pm infers per stack: 3000 for Next, 5173 for Vite.
13 toolsMCP, mirroring the CLI verbs
31projects registered in the README screenshot, 5 running
89%average test coverage in internal/, 11 packages
01 · How it works

From a command in the terminal to a name in the browser

Click through the steps or let them advance. Each step lights up the piece it talks about.

pm run --
any command
Port
preferred or next free
systemd
pm-<proj>.service
Detector
ss + /proc
Caddy :80
project.localhost
Step 1 of 5 · plays on its own
A prefix, not a flag
pm run -- pnpm dev from the project root. If it is not registered, internal/scan looks at package.json, manage.py, go.mod or pubspec.yaml and saves a draft: stack, command, preferred port and how to inject it. A hand-edited field always wins over a rescan.

The minimal version that works: the same command as always, with a prefix in front.

# before
$ pnpm dev
# after
$ pm run -- pnpm dev
tienda: up · http://tienda.localhost (:3000)
02 · Architecture

One core, four faces

CLI, web dashboard, HTTP API and MCP server are projections of the same internal/api layer. A feature is implemented once and shows up in all four.

Topology on the development machine
Clients
pm (CLI)
cobra · pm run, ps, stop, promote…
pm.localhost
single-HTML dashboard, SSE
Agent (MCP)
/mcp · streamable HTTP
HTTP · 127.0.0.1:9797
Daemon
api
internal/api · shared layer
one core
runner
units, ports, collisions, tiers
detector
real system state
registry
JSON catalog in ~/.pm
systemd-run · ss + /proc · docker ps · caddy reload
System
systemd --user
pm-<proj>.service per project
Docker
compose labels
Caddy :80
Caddyfile regenerated from state
Host: project.localhost → 127.0.0.1:<port>
Browser
*.localhost
Chrome resolves it to 127.0.0.1 with no configuration
CLI
cobra · verb first, docker style
Web
a 350-line HTML file, go:embed, SSE
HTTP API
daemon on 127.0.0.1:9797
MCP
streamable HTTP at /mcp · 13 tools
03 · An end-to-end example

A morning with three projects and a container

A made-up case with small numbers, to see what happens at each stage before getting into the challenges.

1
09:10 · ~/tienda
The first project registers itself
Ana types pm run -- pnpm dev. There is no record: scan sees next in package.json and saves a draft with port 3000 and the -p strategy. pm-tienda.service starts, the detector sees the LISTEN on 3000 and Caddy publishes tienda.localhost.
2
09:12 · ~/landing
The second one wants the same port
Another Next app, another 3000. pm answers port 3000 taken → using 3001 and injects -- -p 3001. landing.localhost works the same. Ana did not open a single config file.
3
09:15 · ~/api
Django receives the port differently
For manage.py runserver the port is positional: 0.0.0.0:8000. The stack decides that, not Ana. api.localhost is routed.
4
11:40 · pm ps
Something pm did not start shows up
The table lists the three projects with their port, URL and tier. Below them, a container Ana started by hand on 8080 appears as unmanaged, with its cwd. pm claim 8080 adopts it and from then on it has a name and a stop button.
5
18:00 · pm stop landing
Stopping kills the tree, not just the parent
systemctl --user stop pm-landing takes pnpm, node and their workers down in one go. Before leaving, pm promote api: the unit becomes permanent and will start on its own at the next boot.
6
next day · from an agent
The same verbs, over MCP
The coding agent calls pm_list before assuming a port is free, starts tienda with pm_start instead of improvising a background dev server, and reads pm_logs when something does not come up.
04 · The challenges

Five times the obvious solution was wrong

Each challenge follows the same pattern: what anyone would try first, why it does not work, what was done and the number that sums it up. The technical detail stays folded.

01

Attributing a port to a project without anyone declaring it

The obvious approach
Ask for the port in a per-project config file and trust it.
Why it fails
The declared port and the real port drift apart as soon as there is a collision, a PORT= in the environment or a dev server that decides to hop on its own. And whatever was started outside the tool, by hand or with Docker, is not in any file. The core problem is not the port: it is knowing that the PID listening on 5173 belongs to that project.
cgroup
/proc/<pid>/cgroup
A pm-<proj> at any level of the cgroup. Everything pm launched has it, workers included.
certain
PM_PROJECT
/proc/<pid>/environ
The variable systemd-run injected. Covers the rare case of a process that re-executes itself outside its cgroup.
certain
cwd
/proc/<pid>/cwd
For what pm did not start: if the directory matches a registered project, it is suggested. Otherwise it is unmanaged.
heuristic
What was done
The port is not declared, it is discovered. pm launches with a prefix that translates to systemd-run --user --unit=pm-<proj> --setenv=PM_PROJECT=<proj>, then checks what the cgroup binds in ss -tlnp. Three redundant signals in /proc attribute each listener, and the state it shows is the system's, not what pm believes it has.
3 signalscgroup, PM_PROJECT and cwd. Zero declared ports: the first pm run auto-registers the project with what it found.
Technical detail

The detector parses the output of ss and reads /proc/<pid>/cgroup, environ and cwd with pure functions, separated from the exec, which is what makes them testable with fixtures. Docker is a separate detector: docker ps plus the compose labels.

A listener with no visible PID is a process from another user or from root: it shows up as system and pm does not try to manage it. Requirements verified on the target machine: systemd as PID 1, systemctl --user running, unified cgroup v2 and a readable /proc/<pid>/environ.

02

Port collisions with per-stack injection

The obvious approach
If the port is taken, find the next free one and pass it to the command with --port.
Why it fails
Finding the free port is the easy part. The hard part is telling the process: every stack receives it differently. Vite wants --port, Next wants -p, many servers read PORT=, Django expects it positional and a docker compose up accepts nothing: compose fixes the port. And with npm or pnpm the flag goes after a -- or the package manager swallows it.
StackPrefersHow it receives another port
Vite5173-- --port 5174
Next3000-- -p 3001
Nuxt / Go / others—PORT=… in the environment
Django80000.0.0.0:8001 positional
Flutter web8080--web-port 8081
docker compose—not touched: compose fixes the port
What was done
The injection strategy is inferred with the stack in internal/scan and saved in the registry: flag, environment variable or template. resolvePort picks the port against the live snapshot of the system before starting; the command is rewritten once with the matching strategy. Containers do not take part in the collision.
6 strategiesvite, next, django, flutter, env and docker. Verified end-to-end: the second service hopped to the next port and kept answering by its name.
Technical detail

After launching, waitForPort polls the state until the unit exposes a LISTEN, and that real port is what goes to the registry and the Caddyfile, even if the process had ignored the suggestion. --port N pins the port and --force turns the collision into an error instead of hopping.

Everything scan produces is a draft. A hand-edited field always wins over a rescan; the package manager is chosen from the lockfile (bun, pnpm, yarn, npm).

03

.localhost names with Caddy, and Vite's 403

The obvious approach
Edit /etc/hosts per project and point a proxy at each port.
Why it fails
The browser lives on Windows and the servers in WSL2: the hosts file on one side is useless on the other. And even if the proxy works, Vite dev servers answer 403 host not allowed to any hostname not in server.allowedHosts. Teaching each project to accept its name is exactly the per-project configuration this was meant to avoid.
# ~/.pm/Caddyfile · generated from the live state
http://tienda.localhost:80, http://tienda.pm:80 {
reverse_proxy 127.0.0.1:3000 {
header_up Host localhost:3000 # Vite sees an allowed host
}
}
What was done
Chrome resolves *.localhost to 127.0.0.1 without touching DNS or hosts, so a Caddy on :80 inside WSL routing by Host is enough. The daemon regenerates the Caddyfile from the state and pushes it with caddy reload without dropping connections. The generator rewrites Host to localhost:<port> in every reverse_proxy; the original hostname still arrives in X-Forwarded-Host.
403 → 200on every Vite dev server, without any project touching its configuration.
Technical detail

Each project is published under two hostnames at once: <name>.localhost and <name>.pm, the remote suffix (PM_REMOTE_SUFFIX) meant for reaching it from a phone over a mesh VPN. The Caddyfile also includes a stable route for the dashboard, pm.localhost. If an app ever needed the real Host, the door is a future host_passthrough flag.

Two real problems while installing it. apt install caddy leaves a system caddy.service owning :80 and the admin :2019; the dedicated pm-caddy unit collided and crash-looped, so pm reuses the system Caddy. And with WSL's mirrored networking mode, Caddy died at startup from a transient race on :80: a systemd drop-in with Restart=on-failure and After=network-online.target fixed it.
04

Stopping what pm did not start

The obvious approach
Stopping is systemctl --user stop pm-<proj>. Always.
Why it fails
A project adopted with pm claim has no unit: stop failed with Unit not loaded. And killing only the listening PID orphans its workers, which is exactly the situation the tool exists to avoid.
Does the unit pm-<proj> exist?
→
systemctl --user stop
No. Is it a container with an id?
→
docker stop <id>
No. Is there a visible PID?
→
SIGTERM to the PID and its descendants via /proc
No: port owned by another user or by root.
→
clear error, left untouched
What was done
Stop first checks whether the unit exists. If not, it looks up the project's instance in the live state and stops it the appropriate way: docker stop by container id, or SIGTERM to the PID and all its descendants, walking /proc. Anything without a visible PID is left untouched, and it says why.
3 pathsto stop: unit, container or PID tree. Verified end-to-end on a claimed project.
Technical detail

The same principle opened another door: registering services that live in another WSL instance, whose directory is not reachable locally. Add() requires only the name; Dir became optional and is only used as --working-directory when starting. pm treats them as external: it routes them by hostname as long as the port is reachable, and returns a clear error if you try to start a project registered by name only.

05

Tests that never touch a real daemon

The obvious approach
A tool that launches systemd units and runs docker gets tested by hand, on the machine, and left without tests.
Why it fails
Every package that touches the system ends up uncovered, and those are exactly the ones that break: the runner, the detector, the proxy. And a test that starts real units pollutes ~/.pm, leaves ghost scopes behind and cannot run in CI.
mcp100%
scan100%
webui100%
runner94.5%
registry94.1%
config91.3%
detector90.4%
api89%
proxy84.6%
daemon83.3%
cli54.8%
What was done
The suite runs fully isolated: a temporary PM_HOME, system state injected into the detector, and systemctl, systemd-run and docker replaced by fakes through an exec seam. Parsing of ss, cgroups and environ was extracted into pure functions to test them with fixtures. go test -race ./... never touches a real daemon.
3,477lines of tests for 4,125 lines of Go code. Per-package coverage measured with go test -cover at v0.1.0; cli is the only one below 80% and it is in the retrospective.
Technical detail

The MCP tests do real round-trips over streamable HTTP against a test daemon; the daemon tests cover the API, the SSE streams, the SPA fallback and the serve lifecycle. The embedded dashboard has a test that checks the bundle exists and is served.

"What is running, on which port, and how do I reach it by name."
The whole tool answers that question for the entire machine, and answers it the same way to a person in the terminal, a browser tab and an agent over MCP. What the system already solves (systemd, Caddy, Chrome with *.localhost) is not reimplemented.
05 · Decisions

A single filter: does the system already do this?

If the answer was yes, the system was used. If not, the minimum was written and a seam was left to test it with fakes.

WHY
Why systemd units instead of a custom supervisor?
The target machine already has systemd as PID 1. A transient scope gives for free what a supervisor would have to reimplement: identity by cgroup, death of the whole tree on stop and per-file logs. And above all the tiers: an ephemeral project is a transient unit; a permanent one is an enabled user unit that starts at boot.
Go deeper

pm promote and pm demote are a change of unit type, not a custom state machine. From Windows, a pm.cmd shim on the PATH forwards to wsl.exe -e pm; pm ui and pm open open Windows Chrome at pm.localhost.

Accepted trade-off: pm only works where there is user systemd and cgroup v2. It is in the README's name: built for WSL2 + systemd + Chrome.
WHY
Why Caddy on :80 instead of a custom proxy?
The browser is on Windows and the servers in WSL2. Chrome resolves *.localhost on its own, and a proxy on :80 inside WSL turns tienda.localhost into the real port. Caddy already knows how to reload configuration through its admin API without dropping connections; writing that by hand would mean maintaining a reverse proxy inside a port manager.
Go deeper

The daemon reconciles the Caddyfile with the state on every change and Reload is a silent no-op if Caddy is not installed: everything else keeps working by port. PM_NO_PROXY=1 disables it for secondary daemons and tests.

The cost is depending on a system service that another installation may have left running with its own configuration, which is exactly what happened with the admin on :2019.
WHY
Why is the dashboard a single HTML file with no build step?
It is a 350-line file with no dependencies, embedded with go:embed. Edit, make build, done. Real time goes over SSE, not polling: state, start/stop, promote/demote, streaming logs, adoption of unmanaged listeners, groups and full CRUD. The tone is that of an always-open tab with a live table.
Go deeper

A frontend with a toolchain would have pulled node into the build of a Go binary whose whole point is installing with make install. The price is a hand-written HTML file that grows; as long as it fits on one screen of code, it pays off.

WHY
Why MCP as a mirror of the CLI, served by the daemon?
The daemon is always alive anyway, so any client finds it with one line in .mcp.json. The 13 tools are the CLI verbs over the same internal/api layer: an agent can do exactly what a person would do in the terminal, without a second implementation that drifts.
Go deeper
# .mcp.json
{ "mcpServers": { "pm": { "type": "http", "url": "http://127.0.0.1:9797/mcp" } } }
# tools
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

Streamable HTTP transport with mark3labs/mcp-go. What an agent gains is real freedom to operate processes: check what is running before assuming a port is free, start the project instead of improvising a background dev server, and read the logs when something does not come up.

Alternatives · what they do and what pm adds
overmind / foreman
They run the processes of one Procfile in a session. pm is machine-wide: each project in its own unit, which outlives the terminal, plus discovery of what it did not start.
portless
Gives *.localhost names to Node servers. pm names any stack, resolves collisions and syncs Caddy from the live state, not from a wrapper.
devbox
Reproducible environments with Nix. pm does not manage toolchains; it manages what is running. They compose without conflict.
06 · What I would do differently

Five decisions I would flip today

I did
pm setup that prints manual steps
Today I would
A guided setup that detects Caddy, writes the units and enables them. Today it is a list of commands to copy.
I did
pm-caddy as its own unit
Today I would
Detect a system caddy.service before writing the unit. The admin API on :2019 was already taken and pm-caddy crash-looped until it was disabled.
I did
Dir required when registering a project
Today I would
Optional from the start. It had to be relaxed for services living in another WSL instance, and validation only matters at start time.
I did
"Adopt" button without showing the listener cwd
Today I would
Show cwd and cmdline before claiming: with several unmanaged entries in the list, a port alone does not identify the project.
I did
cobra layer at 54.8% coverage
Today I would
The same fakes discipline the core has, applied to the commands. It is the only package in internal/ below 80%.
07 · Three things to take away

Recap

1
The port is not declared, it is discovered: pm launches in a named cgroup, checks what is listening and attributes each listener with three signals. Whatever you started outside also shows up.
2
Each project answers by its name in the browser. If the port changes, the name follows it, and no project touches its configuration for that.
3
One core, four faces: CLI, web, HTTP API and 13 MCP tools over the same layer. An agent operates your dev servers with the same verbs you do.
Links