Getting kicked out of your npm run dev server every time Claude or Codex decides to run its own?
If you keep a dev server running all day and hand the same project to a coding agent, the agent wants to verify a change, can’t start a server of its own, “fixes” that by killing every node process on the machine — and takes yours down with it.
This is a three-layer fix that ends the fight for good.
And once servers reliably stay alive, the same foundation turns into a dashboard that shows every project’s server at once, in a single colored terminal grid.
You should be able to point your agent to this page and have it help you get setup.
The problem
You keep a dev server running in a terminal tab all day. Then your coding agent needs to verify a change, so it:
- Tries to start its own server → hits the port or Next.js’s
.next/dev/lock - “Fixes” that with
pkill -f "next dev"orkillall node→ murders your server (and every other project’s server, since it matched by process name) - Starts its own copy, which dies when its session ends
- Repeat tomorrow
The root cause: the agent thinks it needs to own the server. It doesn’t. It needs exactly two things — the server reachable (for curl/Playwright verification) and its console readable (for debugging compile/API errors). Both are solvable without ever touching your process.
The setup — three pieces
1. dev — run your server with a mirrored console
Add to ~/.zshrc (or .bashrc):
# Run the current project's dev server, teeing its console to ~/devlogs/<project>.log
# so coding agents can read server output without owning the process.
dev() {
mkdir -p ~/devlogs
local name=${PWD:t} # bash: name=$(basename "$PWD")
echo "console mirrored to ~/devlogs/$name.log"
npm run dev "$@" 2>&1 | tee ~/devlogs/"$name".log
}Type dev instead of npm run dev. Same terminal output as always, but the console is now also a file the agent can tail. The log is named after the project folder, so it works in every project with zero per-project config, and each restart overwrites the log so it never grows unbounded.
Keep ~/devlogs/ outside any synced folder (Dropbox/iCloud) — you don’t want sync churn on every request line.
zsh: command not found: dev? Shell functions only load when.zshrcis sourced, which happens at shell startup. Any terminal tab you opened before adding the function won’t have it. Runsource ~/.zshrcin that tab, or open a new one. (This trips up everyone the first time — the function is fine, the open shell is just stale.)
2. The hook — make killing servers physically impossible
Instructions in CLAUDE.md are advisory, and they’re not even loaded when the agent works in another directory or spawns a subagent. A PreToolUse hook is enforced by the Claude Code harness on every Bash call, in every project, with or without context.
In ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "cmd=$(jq -r ".tool_input.command // empty"); if printf "%s" "$cmd" | grep -qE "(pkill|killall)[^|;&]*\b(next|node|vite|webpack|turbopack|nuxt|dev)\b|kill[^|;&]*pgrep[^|;&]*\b(next|node|vite|webpack|dev)\b"; then printf "%s" "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"BLOCKED: do not kill dev servers by process name - the user keeps dev servers running in the background, often for several projects at once. Reuse the running server instead: check for it with curl on its port (e.g. http://localhost:3000) and rely on hot-reload. Its console output is mirrored to ~/devlogs/<project-folder-name>.log - tail that file to read server logs. If a specific port truly must be freed, find that PID with lsof -ti :PORT and ask the user before killing it.\"}}"; fi",
"statusMessage": "Checking for dev-server kills"
}
]
}
]
}
}What it does:
- Blocks
pkill/killallaimed atnext,node,vite,webpack,turbopack,nuxt, ordev, plus thekill $(pgrep -f …)variant. - The deny message is the clever part: the agent that just got blocked is told, in the same breath, what to do instead — reuse the server via curl, read the console from
~/devlogs/, and only kill by specific port PID after asking. So even an agent with zero context self-corrects on the spot. - Killing by port (
lsof -ti :3000 | xargs kill) is deliberately still allowed — that’s the surgical path.
Trade-off: it string-matches, so a command that merely mentions pkill -f next gets blocked too. Rare, and the agent just rephrases. That’s the price of a dumb-but-unbypassable check.
3. CLAUDE.md — teach the happy path proactively
The hook is the guardrail; this makes the agent do the right thing before hitting it. In a CLAUDE.md that covers your projects (e.g. a parent-directory one):
## Dev servers — reuse, don't restart
A dev server is usually already running (started with the `dev` shell function,
which mirrors the console to `~/devlogs/<project-folder-name>.log`). Rules:
1. **Check for a running server first** (`curl -s -o /dev/null -w "%{http_code}" http://localhost:3000`)
and reuse it — code edits hot-reload, so there's no need for your own instance.
2. **Read the server console from the log mirror**: `tail -100 ~/devlogs/<project>.log`.
Don't ask for pasted terminal output. If the log is missing or stale, the server
was started without `dev` — ask for a restart with `dev`.
3. **Never kill dev servers by process name** — `pkill -f "next dev"` kills servers
from other projects too (a global hook also blocks this). If a port must be freed,
surface it and let the user decide.
4. A Next.js `.next/dev/lock` error when starting a second instance means the server
is already running — find its port and use it, don't clear the lock.Using Codex or another agent too? The hook is Claude Code-specific, but piece 1 and piece 3 are agent-agnostic — drop the same rules into AGENTS.md.
Why three layers
| Layer | Reaches | Enforcement |
|---|---|---|
| CLAUDE.md / AGENTS.md | Sessions where it’s loaded | Advisory — the agent reads and follows |
| PreToolUse hook | Every Bash call, every project, every session — subagents included | Hard block by the harness; can’t be talked past |
| The deny message | The blocked moment itself | Teaches the right move in-context, even with zero loaded context |
The result: you run dev once per project and never get kicked out again. The agent finds your server with curl, reads its console from ~/devlogs/, and hot-reload covers its changes. And if some future context-free session forgets all of this, the hook physically stops it — and hands it the manual.
Part 2: Devdash: Start all your projects in one tab
Once servers stay alive and their consoles are readable, the next want is seeing them all at once — a single iTerm2 tab split into a colored grid, one pane per project. That’s devdash (lives in ~/.zshrc alongside dev).

Open a fresh tab, type devdash, and the tab splits in place into the grid; the pane you typed in becomes the first project’s server. Each pane still mirrors to ~/devlogs/<project>.log, so the kill-hook and agent-readable logs from the sections above keep working unchanged.
Why native iTerm2 and not tmux
This started as a tmux setup (the obvious choice for “many servers, one place”).
But I’m working solo, so I don’t really need tmux. I like this setup because it’s easy to restart just one project as needed.
Usage
devdash # fill the CURRENT tab with the grid (open a fresh tab first if you like)
devdash -w # open in a NEW window insteadLabeling each pane with its project: the title bar (top of each pane) is owned by the running process — Next sets it to next-server, vite to esbuild, etc. — and you generally want apps to control titles (e.g. Claude Code tabs show their task there). Setting the iTerm2 session “Name” doesn’t help: the dev servers clobber it via title escapes. So devdash instead stamps each pane with an iTerm2 badge — a watermark in the pane body that apps cannot override — via a tiny _devdash_badge helper (it emits the 33]1337;SetBadgeFormat=…07 escape, base64-encoded). That’s the glanceable “which project is this” label.
On startup each pane also prints a colored banner (_devdash_banner) with the project name and a ⌘-clickable http://localhost:<port> link in the project’s color — so you can jump straight to a frontend from its pane. (iTerm2 makes any printed URL ⌘-clickable; the banner just makes the right one prominent and on-brand.)
The registry — the only part you edit
Each project is one row across four maps. To add/remove a pane, edit the PROJECTS list; add a registry entry only if the project isn’t a plain npm run dev on its own default port.
local -A DIR=( # path under ~/_Code, if it differs from the label
crossword crossword/frontend
crossword-api crossword/backend
google-what-font google-what-font/client
)
local -A CMD=( # how to start it, if not the default `npm run dev`
crossword-api ".venv/bin/uvicorn app.main:app --reload --port 5556"
google-what-font "npm run dev -- --port 5566"
)
local -A PORT=( # the port it binds (used by the duplicate guard)
vhs-label-maker 3000 rickel-portal 3100 lastcopy 3111
instapicker 3133 crossword 5555 crossword-api 5556
google-what-font 5566
)
local -A TINT=( # dark RRGGBB pane background — the panel's identity
vhs-label-maker 2b2d31 rickel-portal 241043 crossword 000000
crossword-api 14171c instapicker 3a0f29 google-what-font 360f0f
lastcopy 332a07
)
local PROJECTS=(vhs-label-maker rickel-portal lastcopy instapicker
crossword crossword-api google-what-font)Design decisions worth remembering
- One port each — and only vhs-label-maker defaults to :3000. Two servers on one port silently collide (Next slides to
:3001; others hard-failEADDRINUSE). Every other project pins a distinct port in its own config (e.g. instapicker’sserver.jsdefault was changed 3000 → 3133) so it never clashes even when run standalone. - A duplicate-launch guard. Before opening anything,
devdashchecks every port and refuses if one’s in use, naming the offender. This is what stops a second grid from stacking colliding servers on top of a running one. - Tints stay dark on purpose. The color is the panel’s identity, but a bright background makes logs unreadable — so the vivid color is subtle, a dark wash. (Terminals can’t do gradients, so e.g. instapicker gets a solid Instagram-magenta, not the gradient.)
- The “pane 1 is this shell” trick. In tab mode the pane you typed
devdashin becomes the first server. The function colors/names it via AppleScript, then runs that server itself as its final step — rather than typing a command into its own still-running shell, which races. - Multi-part projects are just more panes. crossword is two panes (Next frontend
:5555+ FastAPI backend:5556from its.venv); the frontend’snext.config.tsalready proxies/api→:5556, so they talk to each other.
Reviving one dead pane
If a single server stops (you ^C‘d it, or one crashed), don’t relaunch the whole grid — re-running devdash is blocked anyway because the other panes still hold their ports. Instead: click into the dead pane, press Up, then Enter. The recalled line is that pane’s exact launch command (badge + cd + dev server + logging), so it comes right back.
The one real downside
Because there’s no tmux/supervisor, a server that dies stays dead — most notably, macOS sleep reaps Node/Next dev servers overnight (lighter processes like a uvicorn backend often survive). The pane just drops to a shell prompt. Options, cheapest first:
- Re-run
devdasheach morning — one command, ~10s. Fine if it’s occasional. caffeinate -swhile plugged in — stops the deep sleep that reaps the servers.pm2— a real auto-restart daemon (pm2 start … && pm2 logs). The proper fix if morning-restarts become a daily annoyance; otherwise it’s machinery you don’t need.tmux/zellijare the other persistence answer, with the interface caveats above —zellijat least shows a clickable keybinding bar so there’s nothing to memorize.
Wrapping up
The whole thing is four small pieces that stack. dev makes every server’s console readable. The hook makes killing servers by name impossible — and hands the blocked agent the manual. CLAUDE.md teaches the happy path before the guardrail ever fires. And devdash racks them all into one glanceable grid.
You don’t need all four. Stop after piece 1 and you’ve already fixed the “agent can’t see my compile errors” problem that pushes it to spawn its own server in the first place. But run the full set and the day-to-day changes shape: you type dev (or devdash) once, your agents find your servers with curl and read their consoles from ~/devlogs/, hot-reload covers their edits — and you stop thinking about who owns which process. That was the whole point.
Comments