← Blog
May 20268 min readelectronreacttypescriptai-developmentvibe-codingmacos

Building PortPilot: one afternoon, one pain, zero terminal guessing

How I vibecoded a macOS menu bar app in an afternoon using AI for the full implementation — from idea to deployed .dmg — without a single unverified bug.

Every developer has that recurring tax: you open a new terminal tab, run something, forget what was already running, and spend ten minutes figuring out which process owns port 3000. Is it the Next.js dev server from this morning? The Postgres container you forgot to stop? A stale Vite build? You run lsof -i :3000, get a PID, run ps aux | grep <PID>, get a path, and by then you've lost the context you opened the terminal for in the first place.

I got tired of paying that tax. So I built PortPilot — a macOS menu bar utility that shows every listening port, the project behind it, the framework, the package manager, whether it's Docker — and lets you stop it safely without touching the terminal. I built it in one afternoon using AI for the full implementation. Here's how that actually went.

The idea was specific before any code existed

The reason this worked quickly is that the problem was already fully defined before I started. I did not sit down with a vague "developer tool" concept. I sat down with a precise pain:

  • I want to see which ports are in use right now
  • I want to know what project each one belongs to
  • I want to open the local URL, reveal the folder, or stop the process without opening a terminal
  • I do not want to kill system processes by accident

That specificity is what made the AI-assisted development effective. When you can describe exactly what you need, the model can generate exactly that. Vague prompts produce vague software.

The architecture decision came before the first prompt

I chose Electron before writing a single line of code. The reasoning was straightforward: this app needs access to the operating system — it has to run lsof, ps, and docker ps, read process metadata, and send SIGTERM to arbitrary PIDs. A web app cannot do that. A native Swift app would have been faster to distribute but slower to build. Electron with React and TypeScript gave me a UI I could iterate on quickly and a Node.js main process that could run the shell commands I needed.

The split was important to get right from the start:

  • Electron main process: all shell execution, file system reads, IPC
  • Renderer (React): display only, no shell access, no Node.js
  • Preload: a narrow API bridge between the two

That separation is not just architecture hygiene. It's the security model. The renderer cannot run shell commands directly — it can only call named IPC handlers that the main process validates. This meant that even as the UI evolved through rapid iterations, the dangerous parts stayed in one place.

Vibe coding is not the same as reckless coding

I want to be precise about what "vibecoded" actually means in this context, because the term gets used loosely.

Every piece of code the AI generated, I read before it ran. Not to rewrite it — but to verify it. When the port scanner came back from lsof, I checked that the parsing logic matched the actual output format. When the stop flow used SIGTERM before SIGKILL, I verified that the escalation logic was gated behind a user click, not automatic. When the preload exposed an IPC function, I confirmed it only accepted record IDs — not raw PIDs from the renderer.

The AI handled the implementation. I handled the correctness verification. That division is the whole point. You move fast because the model writes the code. You ship confidently because you check what it wrote before it goes anywhere near production.

This is different from blindly accepting generated code and hoping it works. The iteration loop was: describe what I need precisely → read what comes back → run it → verify the behavior matches the intent → describe the next piece.

The scanner was the hardest part to get right

Port scanning on macOS means parsing lsof output. The challenge is that lsof output is not structured — it's whitespace-delimited text with columns that can vary depending on flags, file descriptors, and process state. Getting the scanner to reliably extract port, PID, and command without false positives took the most iterations of any part of the build.

The key prompt that unlocked it was being specific about what I was filtering out: I did not want every file descriptor — I only wanted IPv4 and IPv6 entries in the LISTEN state. Once I described that constraint precisely, the generated parsing logic was clean and correct.

From there, the projectDetector looked up each PID's working directory via /proc equivalent on macOS (lsof -p <pid> -Fn), walked up the directory tree looking for package.json, go.mod, Cargo.toml, and similar markers, and matched framework from the dependencies block. That detection is heuristic — it can be wrong for monorepos or wrapper scripts — but for the common case it's accurate enough to be useful.

Docker integration almost didn't make the cut

I almost left Docker out of v0.1. The port scanner already picks up containers if they bind to localhost, but the process metadata is the Docker daemon — not the container — so the project detection gives you nothing useful.

Adding docker ps --format json as a parallel scan and joining on port fixed this, but it added a dependency I had to handle carefully: what if Docker is not installed? What if it's installed but not running? The scanner had to degrade gracefully in both cases, not crash.

The AI-generated fallback logic was straightforward: wrap the docker ps call in a try-catch, return an empty array on failure, and display a small indicator in the UI if Docker records would normally be expected but weren't found. Two iterations to get the error handling right. That's the kind of thing that looks simple but is easy to get wrong under time pressure — and getting it correct early meant I never debugged a Docker-related crash later.

The safety model was non-negotiable from the start

I knew before writing any code that I did not want this app to be the thing that accidentally kills a database in production or takes down a system process. The constraints I set were:

  • Protected processes (system-owned, flagged by UID) cannot be stopped at all
  • Stop sends SIGTERM first, shows the process is pending
  • SIGKILL only appears after the process survives SIGTERM and the user explicitly clicks Force Stop
  • shell.openExternal restricted to http://localhost:<port> — not arbitrary URLs
  • All shell commands use execFile with array arguments — no string interpolation, no shell injection surface

These were requirements I stated upfront, not things I added after finding problems. When the AI generated the process stopper, I checked the implementation against each of those constraints before moving on. The stop flow went through three iterations — the first version made Force Stop available immediately, which I corrected by adding the SIGTERM-pending state in between.

One afternoon, shipped

By the end of the afternoon I had a .dmg I could install and use. The menu bar icon showed up, the panel opened, my active ports were there with project names and frameworks, and I could click to open the local URL or kill a dev server without touching the terminal.

That is the thing that AI-assisted development actually changes: the gap between "I have a clear problem" and "I have a working tool" compressed from weeks to hours. Not because the AI is magic, but because it handles the implementation load while you stay focused on whether the result is correct.

The test suite came with the implementation — Vitest fixtures that verified the scanner logic without needing real listening ports. I read the tests the same way I read the production code: confirming they tested the actual behavior I cared about, not just that the function returns something.

What this changes about how I think about tools

I used to filter side project ideas by build time. Something that would take two weekends to reach a usable state was a much harder commitment than something I could knock out in an evening. PortPilot would have been a two-weekend project in the pre-AI workflow — scanner logic, Electron setup, IPC wiring, safe stop handling, Docker integration. Not complex, but time-consuming.

That filter is gone now. Any well-defined problem with a clear scope is fair game for an afternoon. The constraint is not implementation time anymore — it's the quality of the problem definition and the rigor of the verification.

That shift is what I think most people underestimate about AI-assisted development. It does not lower the bar for what ships. It removes time as the limiting factor, which means the bar is now entirely about clarity of thinking and quality of review. You have to know what you want and you have to verify what you get. The rest compresses dramatically.

PortPilot is on GitHub. The .dmg is in releases. The Mac App Store build is in review.