Problem
I run a lot of JavaScript processes at once: dev servers, test watchers, MCP servers, wrangler, things started by bun dev that start other things. Some of them outlive the terminal tab I started them in. Then a port is taken and I don't know by what.
The usual fix is Activity Monitor, or lsof -i :3000 and kill in a terminal. Neither tells you which project a node process belongs to. I wanted one click in the menu bar to see them, kill them and open them. The planning doc puts it as three things that matter equally: see, kill, open.
Constraints
- Native only. No Electron and no web wrapper. A tool that watches for stray Node processes shouldn't be one.
- macOS 13 and up, so I could use SwiftUI's
MenuBarExtrainstead ofNSStatusItem. - No special permissions. Only user-space tools (
ps,lsof) and no full disk access. That meant turning off the App Sandbox, which can't run either. - Every runtime, not just Node. Bun and Deno count too.
- Version managers. My Node comes from fnm, so the binary lives deep in a path like
~/.local/state/fnm_multishells/.... Matching by process name had to survive that.
Architecture
Menu bar label
Icon + live process count
ProcessManager
Polls 2s open, 10s idle
ps + lsof
Process tree and TCP listeners
JSProcess
Framework, short path, uptime
Popover
Dev Servers / Background, kill, open
The app is five Swift files and about 900 lines. ProcessManager is a @MainActor singleton. On a timer it runs ps -axo pid=,ppid=,etime=,comm=,args= on a background queue, then lsof -iTCP -sTCP:LISTEN -nP -Fn for every listening TCP socket on the machine. It publishes the result through @Published, and SwiftUI re-renders. Polling is every 2 seconds with the popover open and every 10 seconds when only the menu bar count needs updating.
Matching the runtime. macOS truncates the comm column to about 15 characters, so fnm's node showed up as /Users/einargudj. The fix checks the basename of argv[0] from the full args first and falls back to comm.
Ports from the whole tree. bun dev doesn't own port 3000. It spawns node .../next dev, which spawns next-server, which does. So I build a parent-to-children map for every process on the system, walk each target's descendants and give the target their ports. Then I hide any target whose ancestor is also a target, so bun dev shows once with port 3000 instead of twice.
Framework detection. The same subtree walk collects descendant args. JSProcess matches them against 25 patterns (Next.js, Vite, Remix, Astro, Wrangler, Vitest and so on) and falls back to the runtime name. It also cuts the args down to a short project path, dropping everything after node_modules.
Actions. Kill sends SIGTERM and rescans after half a second. A port badge opens http://localhost:<port> through NSWorkspace. Processes with ports go under Dev Servers, and the rest go under a collapsed Background section.
What shipped
- The app. Menu bar icon with a live count, a popover grouped into Dev Servers and Background, framework badges in brand colours, uptime, one-click kill and open-in-browser.
- Tests and CI. 21 XCTest cases for the pure functions:
etimeparsing, short-path extraction, framework detection and uptime formatting. GitHub Actions runs them on every push to master, and a tag builds a zipped.appand attaches it to a GitHub release. - Releases. v1.0.0, v1.0, v1.1 and v1.2, all tagged on 4 and 5 March 2026. A separate
release.shsigns with a Developer ID, notarizes, staples and builds a DMG. - Install script.
install.shbuilds a Release, quits the running copy, replaces/Applications/jstop.appand relaunches it. I added it in June because the installed copy had drifted behind the source. - A showcase video made with Remotion, rendered to the GIF in the README.
What I'd do differently
- Test the parsing, not just the helpers. The tests cover small pure functions. The
psparsing, the descendant walk and the deduplication all live inline in one closure inrunPS, so none of it is tested. That's the part most likely to break. - Drive the real UI before tagging. In June I found the process list never rendered. A
ScrollViewinside the self-sizingMenuBarExtrawindow got no height and collapsed to zero. The fix measures content height with aPreferenceKeyand caps the frame at 400 points. - Finish the kill. The plan was
SIGTERM, thenSIGKILLif the process ignored it. OnlySIGTERMshipped, so a hung process just stays in the list. - Match frameworks on tokens, not substrings. A pattern like
/expressmatches any path that contains it. The order of the list does real work, and a new pattern can quietly change existing labels. - Cut the scope out loud. Manual port override was in the first requirements and the Phase 3 plan. It never shipped, and the planning docs still list it as active.
- Keep planning docs honest. I scaffolded the project with a phased plan and an agent running some of the plans.
STATE.mdstopped at 43% on day one, while the app went on to v1.2. Docs that stop being updated mislead the next reader, me included.