A VS Code / Cursor extension for the folder you actually have open: git state, ports, and product dev servers — the same overview as the Immu macOS widget, scoped to the workspace.
Immu lives in an editor tab. Open it with the Immu icon in the editor title bar (next to split editor), the status-bar item, or Immu: Open. The tab is restored after a window reload.
- Overview: one card per project, showing branch, changes, ahead/behind, and which dev servers are up. Each card has Pull and Stop buttons, and the header has Pull all, Fetch all and Stop all for the whole workspace.
- Project page: click a card. The same cards stay in a scrollable sidebar on the left, so you can switch projects in one click. The page shows git (Fetch / Pull / Push and the last git output) and every server, each with Start / Stop, its terminal, its scripts and its ports. Stop all stops every server of that project.
- Press
/to filter projects andEscto go back to the overview. - Each product lists its ports grouped by the command that starts them:
pnpm pro devwith Dashboard and Web App,pnpm pro storybookwith Storybook. Each group has its own Start / Stop, so you always know what a button runs (see Commands and processes). - Start runs that command in an editor terminal. It shows orange while starting and green once ready. Stop sends Ctrl+C, stops the whole process tree the command started, and frees its ports. A command started outside Immu (another terminal, tmux) shows as Up and can be stopped too. Hover a port and click × to kill just the process listening on it.
- While the Immu tab is visible it refreshes every 30 seconds: files, git, and what is running. Nothing is scanned while it is hidden.
- There is no config file. Immu finds repos, lanes, dev commands and ports in the files the project already has (see Discovery).
From Open VSX (Cursor, VSCodium, …): search Immu in the Extensions panel, or
cursor --install-extension immu.immuIt updates itself like any marketplace extension.
From a GitHub Release (VS Code, or a specific version):
gh release download -R n6-studio/immu --pattern '*.vsix' --clobber -D /tmp
cursor --install-extension /tmp/immu-*.vsix --force # or: code --install-extension …From source: npm install, then npm run package and install the generated .vsix, or press F5 (Run Extension) to debug.
The Immu icon (2×2 grid) appears in the editor title bar. Click it, or the status bar item, to open the Immu tab.
npm version patch # bumps package.json and tags vX.Y.Z
git push --follow-tagsThe Release workflow checks the tag matches package.json, runs the tests, builds the .vsix, attaches it to a GitHub Release and publishes it to Open VSX (when the OVSX_PAT secret is set).
Immu is a workspace extension, so in a Cursor / VS Code Remote-SSH window it runs on the remote machine: git, port checks, and ▶ Dev terminals all happen on the server, next to the code. Opening an endpoint goes through the editor's port forwarding, so localhost:4400 on the server opens in your local browser.
Install it on the remote once per host:
- Connect to the host and open your project folder.
- In the remote window, install Immu from the Extensions panel (Open VSX), or run one of the install commands above in its integrated terminal.
- Open the project (or the parent folder of its lanes). Nothing else to set up.
Linux notes: commands run in $SHELL (falling back to bash / sh). Processes, listening ports and working directories are read straight from /proc, so nothing (lsof, ss, procps) has to be installed; only your own user's processes are visible, as with ss -p.
Immu reads what is already on disk. Nothing is written into your repos.
Projects and lanes. An opened folder that is a git repo is one project. A folder that is not a repo shows every git repo directly inside it (_* and dot folders are skipped). Repos that share an origin are lanes of one family: acme-1, acme-3 → lanes 1 and 3, with neighbouring colors and A1 / A3 badges. Opening a single lane still knows its number.
Products and dev commands, first match wins:
- Root scripts that only forward to one workspace package —
"pro": "vp run --filter 'acme-pro'"(alsopnpm --filter,-F,turbo --filter,yarn workspace,npm -w) → product Pro, commandpnpm pro dev. - Otherwise one product per workspace package (
pnpm-workspace.yamlorpackage.jsonworkspaces) with a dev script →pnpm --filter <name> dev. - Otherwise the root dev script → one product named after the package.
- Otherwise one product per folder directly inside the repo that has its own
package.jsonwith a dev script — afront/next to a Python backend → product Front, commandpnpm -C front dev(npm --prefix front run dev,cd front && yarn dev).
The dev script is the first of dev, dev:local, start:dev, develop, serve, start, storybook, so a component library with only a Storybook is a product too. The package manager comes from packageManager, then the lockfile.
A product's other server scripts — any script with dev, develop, start, serve, server, storybook, preview, mock or emulators in its name (docs:dev, dev:mock), but not build:dev or test:start — are offered under ⋯ → Run a script and get their own ports.
Ports and URLs, with the source shown in every tooltip:
PORT_*,*_PORTandPORTfrom.env<.env.development<.env.local<.env.development.local(later wins,${VAR}expanded).PORT_PRO_DASHBOARD→ Pro › Dashboard,PORT_STORYBOOK_PRO→ Pro › Storybook; the rest goes to Platform. The same files inside a product's own package count for that product:PORT=3001inapps/web/.env.local→ Web,STORYBOOK_PORTthere → Web › Storybook;$VARin that package's scripts and configs is read from its own files first.- Ports in the dev script chain:
--port 3000,-p $PORT_X,--listen localhost:3000,PORT=8080,STORYBOOK_PORT=6006,serve -l 3000, including nested commands such asconvex dev --start 'vite dev --port 3000'orsh -c '…'. Then the ports the product's other server scripts ask for the same way:"dev:mock": "vite --port 3001"→ Front › Dev Mock. http(s):///ws(s)://*_URLvalues on localhost that point at a port nothing else declared.- Private-network URLs (
*.internal,*.localhost,*.local,*.test, private / Tailscale IPs) only when they replace a localhost value from a lower env file (PUBLIC_PLATFORM_IDENTITY_URL=https://id.ep1.internalin.env.local). Hosted environments such ashttps://id.dev.acme.devand backends likeVITE_CONVEX_URLstay out. These URLs usually sit behind a local proxy that always accepts connections, so Immu checks them with an HTTP request: a 502/503/504 from the proxy, or no answer at all, counts as down. - Last resort:
server.portinvite.config.*/astro.config.*(literal orNumber(process.env.X ?? 4771)), or the default port of the CLI the dev script runs, by subcommand:vite5173,vite preview4173,next dev3000,astro4321,ng serve4200,gatsby develop8000,storybook dev6006,wrangler dev8787,parcel1234… (vite build,next buildand commands that pass their own--portserve no default). The full table is insrc/discovery/frameworks.ts.
Backing services are not dev servers and stay out: DB_PORT, POSTGRES_PORT, REDIS_PORT, SMTP_PORT and the like, postgres:// / redis:// URLs, and a *_PORT whose *_HOST is another machine (SMTP_HOST=smtp.sendgrid.net).
The ready port of a product is its first plain port (Pro Dashboard before Pro Web App, never Storybook, mock ports or another script's ports).
On every refresh (the button, or every 30s while the tab is visible) Immu reads every TCP listener and the process table (/proc on Linux; lsof and ps on macOS). From each listener it climbs the parent processes up to the command that was typed into a shell or run by a task, stopping at the shell, tmux, the terminal or the editor. vite under sst dev under pnpm pro dev belongs to pnpm pro dev. Runners are shown as you would type them (node …/pnpm.cjs pro dev → pnpm pro dev), and pnpm dev typed inside products/pro counts as the same script as pnpm pro dev at the root.
Each port of a product goes under a command, strongest evidence first:
- Running: the command it listens under right now. Ports no file declares (
sston 13557) are added to that product too. - Learned: the command it was last seen under. Immu remembers this in editor storage, per repo and by port name rather than number, so one lane teaches the others. ⋯ → Forget learned commands clears it.
- Declared: before anything has run, Immu follows each script through the workspace —
pnpm --filter/-F/-r/-C,yarn workspace,npm -w,turbo run,vp run,nx,lerna,run-p/npm-run-all,concurrently "npm:dev:*",cd dir &&, and wrappers such ascross-env,dotenv,npx— and looks at what the scripts it reaches say: the port's env var ($PORT_STORYBOOK_PRO), a literal port (--port 4460,PORT=4460), the same env var in the config of a tool the script runs (sst.config.tsforsst dev,vite.config.tsforvite,.storybook/main.tsforstorybook, next / nuxt / astro / webpack / wrangler / compose…), or the default port of the framework it runs. The dev command wins a tie; scripts the dev command runs itself are not separate commands. - Named: a script of the product named like the port (
storybook↔ Storybook,dev:mock↔ Mock Convex), unless the dev script already runs that script. - Otherwise the dev command. Once the dev command has been seen running without a port, that port moves to Not started by these commands instead.
SST. A script that runs sst dev — directly or through wrappers such as pnpm with-env sst dev, whose arguments pnpm appends to the script — is read through its sst.config.ts and the local modules it imports, without running them. Every new sst.<namespace>.<Type>("Name", { … }) with something to run in dev is listed under the command as sst dev starts: a sst.x.DevCommand with its dev.command / dev.directory / dev.title, a frontend (TanStackStart, Nextjs, Astro, Remix, SvelteKit, Nuxt, SolidStart, React, Analog) with its dev.command (npm run dev unless set) in its path. Each component's scripts are followed like any other, and a component serves the ports its dev.url points at: url: platformDevUrls.hqWebsite → devUrl("PUBLIC_PLATFORM_HQ_WEBSITE_URL", "http://localhost:4200") → that variable's port, or the local value it replaced when .env.local points it at a hosted stage.
Technologies. Each command, SST component and port shows icons (Simple Icons) for what it runs: SST, TanStack Start, Vite / Vite+, Next.js, Storybook, Convex, Bun, Node.js… — from the programs its scripts reach, the SST components it starts and the processes listening under it. Package managers and shell plumbing are not shown; Node.js only when nothing more specific runs. Processes are named by the command their package installs: node …/vite-plus/dist/cli.js dev shows as vp dev.
Aliases. A private URL (https://ep6.internal) is another name for the local port it replaced, so it shows as a chip under that port's row rather than as a server of its own. Hover a port for why it sits under its command (products/pro/sst.config.ts uses PORT_PRO_DASHBOARD, vite (pid 4521) listens under pnpm pro dev), the file it was found in, and which process listens on it — also when that process is not the project's: ControlCenter (pid 512), not started from this project is macOS AirPlay on 5000. Hover a product's name for its dev command, ready port and where it was found.
Lanes sharing ports. Lanes of one repo usually declare the same ports. When lane 1's server holds a port lane 2 declares too, lane 2 does not show as up: the port gets a hollow orange dot and in use by acme-1, and lane 2's Stop leaves it alone.
Stopping. Stop ends the command's whole process tree (SIGTERM, then SIGKILL after 2.5 s), then anything still on its ports. The tree is recorded before Ctrl+C reaches the terminal, so a runner that exits on Ctrl+C and orphans its children (sst dev does) does not take them out of reach, and it is re-read until nothing is left, so children spawned while shutting down go too. A launcher only counts when it runs inside the project, so a daemon such as pm2 or Docker is never taken for the command and never stopped as a whole. Freeing a port never kills infrastructure that holds it for something else — Docker / OrbStack / Podman forwarding a container, an ssh or kubectl tunnel, macOS ControlCenter / rapportd — Immu says what holds it instead. Before stopping a tree Immu checks the pid still runs the same command line, and it never signals the editor or its parents.
A leftover listening on a port (an orphaned vp dev still on :9200) stays under the command that declares the port, marked Left over from an earlier run; Stop ends it and its children. Leftovers are never learned as commands of their own.
Leftovers. Some tools leave processes running after they exit — sst dev's workers and the servers it started, adopted by launchd / init. After a Stop, Immu also stops orphans that run inside the command's directory (by working directory, or a path on their command line such as …/acme/node_modules/.bin/vite) and started during that run. Orphans left by earlier runs — a Ctrl+C in your own terminal — show on the project page as N leftover processes from earlier runs with Clean up; Stop all for a project stops them too. Apps, system daemons, agents (ssh-agent, gpg-agent), multiplexers (tmux, screen), git, the editor and infrastructure are never counted. npm run discover -- --running lists them.
When one command serves several products (a root pnpm dev running pnpm -r dev, turbo dev), each card says Also serves …, and Stop on one product stops only the branches of the tree that serve its ports. Some runners shut every child down once one exits (pnpm -r --parallel does, even with --no-bail); Immu checks afterwards and tells you when the runner took the others down too. Stop all stops such commands entirely.
Exited commands. A command Immu started in a terminal can end while the terminal stays open (Ctrl+C in it, a crash, a script that finishes). Immu watches what runs under the terminal's shell and shows it as Exited (or Failed if it never got ready); the terminal stays open so you can read why.
Changes to .env*, package.json, pnpm-workspace.yaml, the configs that can name a port (vite.config.*, astro.config.*, sst.config.*, .storybook/main.*, wrangler.toml…) or a new .git re-run discovery automatically.
-
Immu: Set Dev Command (or ⋯ → Edit dev command… on a server) stores a command and ready port in extension storage, per repo origin, so it applies to every lane. Leave it empty to go back to the discovered one.
-
Git commands default to:
Action Default status git status --porcelain=v2 --branchfetch git fetch --prunepull git pull --rebase --autostash origin {defaultBranch}push git push --set-upstream origin $(git symbolic-ref --short HEAD){defaultBranch}is origin's default branch, read from.git/refs/remotes/origin/HEAD(else whichever ofmain/master/develop/trunkexists;mainif none). If it is missing or stale,git remote set-head origin --autofixes it.Override any of them with the
immu.gitCommandssetting (user, workspace or folder settings);{defaultBranch}works there too:
{ "immu.gitCommands": { "pull": "git pull --ff-only origin {defaultBranch}" } }.vscode/immu.json files are no longer read; you can delete them.
| Command | What it does |
|---|---|
| Immu: Open | Open (or focus) the Immu tab |
| Immu: Refresh | Re-run discovery and scan for running dev servers |
| Immu: Pull All Repos | Run the pull command in every repo at once, with one summary of what failed |
| Immu: Fetch All Repos | Run the fetch command in every repo at once |
| Immu: Start / Stop Dev Server | Quick-pick a command (startable ones, or running ones to stop) |
| Immu: Stop Project Servers | Quick-pick a project and stop all of its servers |
| Immu: Set Dev Command | Override a product's command / ready port |
| Immu: Stop All Dev Servers | Stop every running command and free its ports, in every project |
Settings: immu.gitCommands, immu.portIntervalMs (5000), immu.gitIntervalMs (30000).
Run discovery without the editor to see what it finds and why:
npm run discover -- ~/Code/acme # products, commands, ports, and the file each came from
npm run discover -- --running ~/Code/acme # plus what listens under each project right now
npm run discover -- --json ~/Code/acme # the raw resolved projectsacme-1 lane 1 ~/Code/acme-1
Pro $ pnpm pro dev ready on :4400
found in: package.json script "pro" → acme-pro
:4400 Dashboard .env.local PORT_PRO_DASHBOARD
declared by dev: products/pro/sst.config.ts uses PORT_PRO_DASHBOARD
:4460 Storybook .env.local PORT_STORYBOOK_PRO
declared by storybook: script "storybook" in products/pro/package.json uses PORT_STORYBOOK_PRO
A port that is missing, or sits under the wrong command, usually shows up here with the rule that put it there.
src/
extension.ts activate, commands, file watchers
model.ts live git / ports / ▶ Dev state, stop logic, snapshots for the tab
protocol.ts messages and snapshots between the extension and the tab
tech.ts programs / processes → technologies, for the icons
discovery/
index.ts folder → lanes → products → endpoints (entry point)
lanes.ts git.ts repos, origins, lane numbers, default branch
packages.ts package.json, workspaces, package managers, how to run a script
products.ts which packages are products, which scripts start servers
env.ts layered .env files, port variable names, backing services
commandline.ts tokenizing scripts, ports on command lines, unwrapping cross-env / npx
frameworks.ts dev-server CLIs: default port per subcommand, config files
sst.ts what `sst dev` starts, read from sst.config.ts and its imports
endpoints.ts the port discovery steps above, in order
scriptgraph.ts which scripts a script reaches across the workspace, declared ports
runtime.ts processes.ts procfs.ts listeners, process tree, launchers (lsof / ps / /proc)
overrides.ts stored dev commands and learned launches (editor storage)
report.ts cli.ts `npm run discover`
products/ endpoint grouping by product and by command, script candidates
git/ ports/ status, TCP / HTTP check, kill
host/ editor tab, terminals, status bar
webview/ tab UI (overview, project page), icons.ts (Simple Icons); preview.html renders it with
sample data (`?project=/epicparty-06` opens a project page)