Skip to content
011010Public

About

Port and process lease coordinator for parallel AI coding-agent sessions

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Portzilla

portzilla

Lease registry for localhost ports — stop killing your sibling's dev server.

CI crates.io npm MIT

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. portzilla is the layer that prevents the conflict.

  • Claim, don't kill — portzilla claim 3000 --tag next-dev gives 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 3000 tells you who owns a port. JSON everywhere so agents can consume it directly.
  • Guard by default — intercepts kill, lsof | xargs kill, fuser -k, kill-port and 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.

Portzilla preventing a port conflict

Contents

Quick start

$ 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-dev

Data-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 3000

The 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.

Demo

$ 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 prune

Output is real — captured with PORTZILLA_DATA_DIR pointed at a temp directory with two live processes.

Install

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 sh

npm (same native binary):

$ npm install -g portzilla

Binaries for Linux x86_64/ARM64, macOS Intel/Apple Silicon, Windows x86_64/ARM64 — each with .sha256 checksums.

Commands

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.

Agent skill

For OpenCode agents, install the checked-in portzilla skill explicitly:

$ mkdir -p .opencode/skills/portzilla
$ portzilla init skill > .opencode/skills/portzilla/SKILL.md

init 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).

Watch mode

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.

MCP server

For agents with MCP access (Claude Code, etc.) — typed JSON in/out instead of shelling out:

$ claude mcp add portzilla -- portzilla serve --mcp

Exposes 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.

Audit history

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 clear

History 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.

Kill guard

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.

Data file & config

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.

Limitations

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.

Roadmap

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.

License

MIT — see LICENSE.

About

Port and process lease coordinator for parallel AI coding-agent sessions

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages