How to kill a whole process tree on macOS without respawns
You kill a runaway agent, and its dev server is still holding port 3000. You kill the dev server, and the watcher starts a new one. On macOS, kill stops exactly one process, and AI agents rarely run as one process. This guide covers two reliable ways to stop a whole tree, and how to catch the processes that escape it.
Why killing one process is not enough
- Children outlive their parent. When a process exits, its children are handed to
launchd(pid 1) and keep running. Kill a Claude Code session and its MCP servers and dev servers stay up. - Supervisors respawn. Kill a child of
npm run dev,nodemonor a test watcher and the parent starts a replacement. Killing from the bottom up loses this race. - macOS has no built-in tree kill.
pkill -P <PID>only reaches direct children, andpstreeis not installed by default.
First, look at the tree
Define a small function that prints a process and all of its descendants. It works in both zsh (the macOS default) and bash:
tree_pids() { echo "$1"; for c in $(pgrep -P "$1"); do tree_pids "$c"; done; }
ROOT=48211 # the agent's process id
ps -o pid,ppid,pgid,command -p "$(tree_pids $ROOT | paste -sd, -)"In Activity Monitor you get a similar view from View → All Processes, Hierarchically.
Method 1: kill the process group
Processes started together usually share a process group. Sending a signal to a negative number sends it to the whole group at once, so nothing gets a chance to respawn:
PGID=$(ps -o pgid= -p 48211 | tr -d ' ')
ps -o pgid= -p $$ # your own shell's group: make sure it is different
kill -TERM -- -$PGID
sleep 3
kill -KILL -- -$PGID 2>/dev/nullThis is fast, but it misses two kinds of process. Anything that called setsid or daemonized, which many dev servers do, has left the group. And a shell with job control puts each pipeline in its own group, so an agent's commands may be spread over several.
Method 2: freeze, rescan, then terminate
The thorough approach works from the parent links instead of the group. Freeze every member first with SIGSTOP, so no process can start or restart anything while you work, then signal them all:
tree_pids() { echo "$1"; for c in $(pgrep -P "$1"); do tree_pids "$c"; done; }
ROOT=48211
kill -STOP $(tree_pids $ROOT) # freeze: nothing can fork or respawn now
kill -STOP $(tree_pids $ROOT) # rescan: catch children forked before the freeze landed
PIDS=$(tree_pids $ROOT | paste -sd, -) # remember the frozen tree
kill -TERM $(tree_pids $ROOT) # queued while stopped
kill -CONT $(tree_pids $ROOT) # resume: every member receives SIGTERM together
sleep 3
ps -o pid=,command= -p "$PIDS" # anything left?
kill -KILL $(ps -o pid= -p "$PIDS") 2>/dev/null # force whatever ignored SIGTERMA stopped process does not act on SIGTERM until it is continued, so after SIGCONT the whole tree gets the request at the same moment and there is no parent left to restart anything. Check the list before the final SIGKILL: process ids are reused, and a few seconds is enough for an unrelated process to pick up a freed one.
Catching processes that escaped the tree
A dev server that detached with setsid and whose parent already exited is no longer anyone's descendant. Two ways to find it:
# Processes working inside the project folder
lsof -d cwd | grep "$HOME/code/my-project"
# Whatever still holds the port you expected to be free
lsof -nP -iTCP:3000 -sTCP:LISTENClaude Code and Codex also mark the commands they run with session variables (CLAUDE_CODE_SESSION_ID and CODEX_THREAD_ID, as documented by the Agent Meter project). For your own processes, ps -Eww -p <PID> prints the environment so you can check.
Safety rules worth keeping
- Never signal pid 1, your own shell, or any process that is an ancestor of the shell you are typing in.
- Only act on processes owned by your user; leave other users' and system processes alone.
- Re-check a process's command and start time right before each signal if any time has passed.
- Prefer
SIGTERMand give it a few seconds. UseSIGKILLonly for what remains.
How Agents Monitor kills a tree
Agents Monitor runs the freeze, rescan and terminate sequence above for you. It follows each agent's lineage while it runs, so detached dev servers stay in the tree, tagged detached, instead of slipping away. Before anything happens it shows a confirmation listing every target, and each kill lands in a local log.
It also enforces the safety rules: it refuses pid 1, itself, its own parents and other users' processes, and rechecks each process's identity before every signal. Processes matched only by their environment are shown as Unconfirmed and are never killed.