Lease registry for localhost ports — stop killing your sibling's dev server.
AI coding agents (Claude Code, Cursor, etc.) run parallel sessions in git worktrees — each starting its own dev server. Today they coordinate by killing whatever is on a port.
portzillais the layer that prevents the conflict.
- Claim, don't kill —
portzilla claim 3000 --tag next-devgives you a port with owner PID + purpose tag. Conflicts and already-bound ports get the next available port instead of stealing. - Ask before you kill —
portzilla who 3000tells you who owns a port. JSON everywhere so agents can consume it directly. - Guard by default — intercepts
kill,lsof | xargs kill,fuser -k,kill-portand blocks kills against another session's live lease.
Port allocators and proxies such as Portless solve addressability and automatic port selection. portzilla focuses on a different boundary: declaring which agent session owns a process and preventing another agent from killing it. It stays daemon-less by default, requires no local proxy or CA, and works with any server command that consumes the assigned port.
- Quick start
- Demo
- Install
- Commands
- Agent skill
- Watch mode
- MCP server
- Kill guard
- Data file & config
- Limitations
$ portzilla claim 3000 --tag next-dev --session "$CLAUDE_CODE_SESSION_ID"
claimed port 3000 for pid 57107 (tag: next-dev)
$ portzilla who 3000
port: 3000
pid: 57107
tag: next-dev
status: alive
$ portzilla ls
PORT PID STATUS AGE TAG
3000 57107 alive 5s next-devData-producing commands accept --json for agent consumption. See CLI reference.
Prefer run when starting a server — it claims the port and holds the lease for the real server process:
$ portzilla run 3000 --tag "vite dev" -- sh -c 'npm run dev -- --port "$PORTZILLA_PORT"'
running on port 3000The exact child command is framework-specific: whatever starts your server must read the assigned port from $PORTZILLA_PORT. On conflict run reassigns to the next free port and reports it on stderr; the server child's stdout stays untouched.
$ portzilla claim 3000 --tag next-dev --pid 57107
claimed port 3000 for pid 57107 (tag: next-dev)
$ portzilla claim 3000 --tag vite-dev --pid 57108
port 3000 is busy (lease conflict); claimed port 3001 instead for pid 57108 (tag: vite-dev)
$ portzilla who 3001 --json
{"port":3001,"pid":57108,"tag":"vite-dev","created_at":1785959877,"session":null,"process_start_time":1785959876,"age_secs":3,"alive":true}
$ portzilla release 3001
warning: released port 3001 whose owning pid 57108 is still alive
port: 3001
pid: 57108
tag: vite-dev
status: alive
$ portzilla prune
no dead leases to pruneOutput is real — captured with
PORTZILLA_DATA_DIRpointed at a temp directory with two live processes.
Cargo:
$ cargo install portzilla
# from local checkout:
$ cargo install --path .curl (prebuilt binary to ~/.local/bin, falls back to cargo):
$ curl --proto '=https' --tlsv1.2 -LsSf https://raw.githubusercontent.com/011010/portzilla/main/scripts/install.sh | sh
# specific version / directory:
$ curl --proto '=https' --tlsv1.2 -LsSf https://raw.githubusercontent.com/011010/portzilla/main/scripts/install.sh | PORTZILLA_VERSION=0.4.0 PORTZILLA_INSTALL_DIR=/usr/local/bin shnpm (same native binary):
$ npm install -g portzillaBinaries for Linux x86_64/ARM64, macOS Intel/Apple Silicon, Windows x86_64/ARM64 — each with .sha256 checksums.
| Command | What it does |
|---|---|
portzilla claim <PORT> --tag <TAG> [--pid <PID>] [--session <S>] |
Claim a port; on conflict with a live lease from another PID, auto-claims next free port |
portzilla ls |
List all leases (PORT PID STATUS AGE TAG) |
portzilla who <PORT> |
Show lease on a port (exit 2 if none) |
portzilla release <PORT> |
Remove lease (always wins; warns if PID still alive) |
portzilla prune |
Remove all leases whose PID is dead |
portzilla run <PORT> --tag <TAG> [--session <S>] -- <cmd...> |
Claim a port and run a server with it; the lease follows the child PID |
portzilla watch [--interval <SECONDS>] |
Repeatedly prune leases whose recorded PID is dead |
Data-producing commands are file-locked and support --json. Full flags, conflict semantics, exit codes and JSON shapes → docs/CLI.md.
For OpenCode agents, install the checked-in portzilla skill explicitly:
$ mkdir -p .opencode/skills/portzilla
$ portzilla init skill > .opencode/skills/portzilla/SKILL.mdinit skill only prints the skill file to stdout — it never writes configuration for you; the redirect above is what saves it. The skill teaches the agent to start servers with portzilla run and $PORTZILLA_PORT. Kill-guard setup stays separate: portzilla init opencode prints the portzilla.js plugin shim (see Kill guard).
portzilla watch is an optional foreground watcher. It is not a central IPC
daemon: it uses the same locked local state file as the other commands, does
not listen for clients, and agents continue to use the CLI or MCP server for
claims and queries.
$ portzilla watch --interval 30
no leases pruned
pruned port 3000 (pid 57107, tag: next-dev)The watcher validates that --interval is a positive number of seconds and
defaults to 60. It runs one prune cycle immediately, waits for the interval,
then repeats until Ctrl-C. Each cycle checks recorded process liveness, not
lease age, and removes only leases whose recorded server process is no longer
alive. For a new lease, preservation requires a verified process identity: the
current process must match both the recorded PID and process_start_time. If
identity could not be resolved when the lease was created, it is intentionally
unverified/dead and may be pruned even when that numeric PID currently exists.
Legacy leases without identity metadata retain PID-only liveness behavior. If
an agent exits while a new lease's verified recorded server process remains
alive, that lease is preserved.
Ordinary store errors are printed to stderr and retried on the next cycle. A startup error, such as an inability to open the data directory, is fatal and exits the command instead of entering the retry loop. Ctrl-C shuts down the watcher successfully.
With --json, stdout contains one event per completed cycle, including empty
cycles:
{"event":"watch_cycle","pruned":[]}
{"event":"watch_cycle","pruned":[{"port":3000,"pid":57107,"tag":"next-dev","created_at":1785959877,"session":null,"process_start_time":1785959876,"age_secs":3,"alive":false}]}Diagnostics, including retry and shutdown messages, go to stderr. See the CLI reference for the complete event shape.
For agents with MCP access (Claude Code, etc.) — typed JSON in/out instead of shelling out:
$ claude mcp add portzilla -- portzilla serve --mcpExposes claim, who, ls, release, prune, and read-only history as MCP tools with the same JSON shapes as --json. See docs/CLI.md#mcp-server.
Portzilla keeps the latest 10,000 operational events alongside the lease state. Lease changes, managed-process exits, and guard denies/warnings are recorded; guard allows and raw shell commands are not.
$ portzilla history --json
$ portzilla history --session my-session --event guard_denied
$ portzilla history clearHistory is newest-first and supports --session, --port, --event, --before, and --limit. Hook and guard event writes are best-effort: a journal failure never changes an allow/deny decision. The MCP server exposes the same read-only history query; clearing history is CLI-only. See docs/CLI.md#history.
Makes checking ownership the default — blocks kill commands that would kill another session's live leased process.
Recognizes kill <pid>, pkill/killall, lsof -ti:<port> | xargs kill, fuser -k <port>/tcp, kill-port <port> (including sudo, env wrappers and sh -c payloads). Verdict: Allow / Deny (live foreign lease) / Warn (unresolvable).
| Harness | Hook | Deny reaches model? | Own-lease? |
|---|---|---|---|
| Claude Code | PreToolUse — portzilla hook claude-code |
Yes (permissionDecisionReason) |
Yes ($CLAUDE_CODE_SESSION_ID) |
| Cursor | beforeShellExecution — portzilla hook cursor |
Likely (agent_message) |
No |
| Gemini CLI | BeforeTool — portzilla hook gemini |
Yes (Deny), no Warn channel | No |
| Codex CLI | PreToolUse — portzilla hook codex |
Yes (Deny + Warn via additionalContext) |
No |
| Kimi CLI | PreToolUse — portzilla hook kimi |
Yes (stderr on exit 2) | No |
| OpenCode | Plugin shim — portzilla init opencode |
Yes (Deny + Warn) | Yes ($PORTZILLA_SESSION) |
| Windsurf | pre_run_command — portzilla hook windsurf |
Yes (stderr) | No |
| Anything else | portzilla guard -- <cmd> |
N/A (exit 2) | Yes (--session / $PORTZILLA_SESSION) |
Setup: portzilla init <harness> prints the snippet to add — never writes files for you. Guard fails open by default; PORTZILLA_FAIL_CLOSED=1 opts into fail-closed.
Full harness setup, sh -c unwrapping rules, and fail-open/closed semantics → docs/GUARD.md.
Resolution order: PORTZILLA_DATA_DIR → $XDG_DATA_HOME/portzilla → ~/.local/share/portzilla. State at leases.json (atomic write + leases.json.lock). New writes use the v3 envelope, which stores leases and bounded audit history together.
PORTZILLA_DATA_DIR isolates tests/CI. tag max 1024 chars, session max 512, hook stdin capped at 1 MiB. New leases persist process_start_time when available; reassignment JSON includes reassignment_reason (lease_conflict or os_occupied). Claims for an explicit PID that does not exist yet are retained as unverified/dead records, not as ownership promises. The normal CLI default attributes a claim to its live parent process. See docs/CLI.md.
PID reuse. New leases record process_start_time when it can be resolved and require a verified match for both the PID and start time during liveness checks. New leases whose identity cannot be resolved are intentionally unverified/dead and can be pruned even if the numeric PID exists. Legacy leases without identity metadata retain PID-only checks. sysinfo reports start times at one-second resolution, so a PID recycled within the same second cannot be distinguished perfectly. Checks are point-in-time: prune sweeps on demand, and the optional foreground watch command repeats those sweeps. An active expiry daemon remains on the roadmap. See PRD non-goals.
OS occupancy. claim also probes IPv4 wildcard/loopback and usable IPv6 wildcard/loopback addresses. A bound but unregistered port is skipped and reports reassignment_reason: "os_occupied"; socket state can change immediately after the probe, so claiming is coordination, not a reservation.
State-file versions. New writes use a versioned leases.json envelope (format_version: 2) so newer identity fields cannot be silently discarded. Legacy bare-array files are read and upgraded on the next write. Older binaries cannot safely read v2 files and must be upgraded before using the same data directory; unknown future versions are rejected without rewriting the file.
portzilla run starts one server command and waits for it, but Portzilla does not stop, restart, or supervise arbitrary servers. It does not enforce a firewall/sandbox or coordinate across machines.
v0.1: claim/ls/who/release/prune + JSON + locked state. v0.1.x: MCP server. v0.2: kill guard + harness adapters + portzilla guard. v0.3: portzilla run + installable agent skill + audit history. The optional foreground watch command is implemented; an active daemon remains future work. Full plan → docs/ROADMAP.md. Release procedure → docs/RELEASING.md.
MIT — see LICENSE.

