Post-Mortem ⏱️ 6 min read

Stop-Process on WindowsTerminal.exe Killed Every Claude Code Tab We Had

TL;DR

>> The symptom: Every open Windows Terminal tab died at once — four separate Claude Code agents, gone in the same second.

>> The wrong suspect: A server restart had happened two minutes earlier. Looked causal. Wasn't.

>> The real cause: One worker ran Stop-Process -Id 12480 -Force to close its own scratch tab. PID 12480 was WindowsTerminal.exe — the single process hosting every tab, not just that worker's.

>> The fix: A PreToolUse hook that denies any command killing WindowsTerminal by name or live PID, plus a reaper/watchdog guard that only ever kills a stuck agent's launching shell, never a reused PID.

>> The proof: the exact 01:03:19 command is now denied; a live restart with a probe tab open left all other tabs untouched.
Running more than one Claude Code agent in Windows Terminal tabs? Godmode+'s dedicated verify pass is built to confirm what actually happened before calling anything fixed — the same discipline that ruled out the restart and found the real PID. SEE GODMODE+ →

The symptom: four agents, one simultaneous death

Four separate Claude Code agents were running in four tabs of the same Windows Terminal window — one doing a merge, others idle between turns. All four died in the same second. Not crashed with an error each in turn. Not one at a time. Simultaneously, as if something had reached in and pulled the plug on the window itself.

That's the tell that separates "one agent broke" from "the host broke." Four independent Claude Code sessions do not fail at the same timestamp by coincidence. Something one level up — the terminal process hosting all four — was the thing that actually died.

The wrong suspect: a restart two minutes earlier

The obvious first theory: a server restart had run at 01:01, two minutes before the tabs died. Restarts are exactly the kind of event that plausibly cascades into killed processes. It fit the timeline. It was also innocent — and confirming that meant reading the actual transcript instead of trusting the correlation.

transcript · timestamps local (+10h from UTC)
01:00:56bash stop.sh → "stopped pid 2173679"
01:01:03bash start.sh → "server up (pid 2189853)"
01:02:31wt.exe new-tab --title screens-restart-verify cmd
01:03:15Get-Process wt,WindowsTerminal → 12480 WindowsTerminal
01:03:19Stop-Process -Id 12480 -Force → exit 137

The restart at 01:01 was clean: old server stopped, new server up two seconds later, nothing unusual. A worker separate from the restart had opened its own scratch tab at 01:02:31 to verify the restart worked, listed running processes to find that scratch tab's window, and at 01:03:19 killed the PID it found — 12480.

The real bug: a scratch tab and the whole window share one PID

Windows Terminal is one process (WindowsTerminal.exe) hosting every tab inside it, the same way one browser process hosts every tab in your browser. There is no separate OS process per tab. When the worker asked "which process is my scratch tab," the honest answer was the same PID as everyone else's tab — because that's genuinely what a tab is: a view inside one shared window process, not a process of its own.

The worker's intent was narrow: close one scratch window it no longer needed. The tool it reached for, Stop-Process -Id 12480 -Force, doesn't know about intent. It kills the process. The process was the window. The window held four other agents' work.

What the worker meant

"Close the one tab I opened to verify the restart, and only that tab."

What Stop-Process actually did

Force-killed the single WindowsTerminal.exe process, which happened to be hosting every other agent's tab too — there was only ever one to kill.

Forensics: reading the transcript instead of trusting the clock

Nothing in stop.sh, start.sh, the server process, the supervisor, or the reaper had logged a kill at that moment. No crash in the event log. No husk-close line, no reaper line. The only thing that explained a simultaneous death of every tab was a command that targeted the terminal host directly — which meant checking the one place that records every command a session actually ran: its own transcript, timestamp by timestamp, until the exact line that fired at 01:03:19 turned up.

tanks-stage-4 was still making tool calls at 01:03:11 (after the restart) -> ruled out
watchdog logged STALLED at 01:03:35 (an effect, not a cause) -> ruled out
no kill in stop.sh / start.sh / server.py / supervisor / reaper -> ruled out
worker transcript, 01:03:19: Stop-Process -Id 12480 -Force -> exit 137
Get-Process at 01:03:15 in the same transcript: 12480 = WindowsTerminal -> confirmed

The fix: deny the command, and narrow what the reaper is allowed to kill

The fix has two parts, because two different actors can produce the same fatal command: a human or agent typing it directly, and the machine's own automated cleanup (the reaper, the watchdog) doing it by accident.

1. A PreToolUse hook that denies the command outright

A new hook runs before any Bash or PowerShell tool call and checks whether it targets WindowsTerminal — by name, or by a literal PID that a live tasklist lookup says currently belongs to WindowsTerminal.exe.

# ~/.claude/hooks/wt-guard.js — PreToolUse, registered for Bash|PowerShell + if (killsProcessByNameOrPid(cmd, "WindowsTerminal.exe")) { + return deny( + "pid " + pid + " is WindowsTerminal.exe — killing it closes every tab. " + + "To close your own scratch tab, use wt-send.ps1 -Title <t> -Message exit." + ); + }

Running sessions picked the guard up live, no restart needed: a PowerShell probe (Get-Process WindowsTerminal | Stop-Process -WhatIf) was blocked, and a bash probe (kill -0 56636 against the live WindowsTerminal PID) was blocked with the exact reason — "pid 56636 is WindowsTerminal.exe."

2. The reaper and watchdog stopped trusting a remembered PID

The guard covers a typed command. It doesn't cover automation that kills a stored PID from earlier, which is a second, quieter version of the same bug: the reaper used to kill a stuck Claude session's parent process by a PID it had recorded minutes or hours earlier. PIDs get reused by Windows. If that recorded "parent" PID had since been recycled to WindowsTerminal.exe, the reaper would kill the terminal host on its own, with no human in the loop at all.

# server.py REAP_PARENT_PS / watchdog.py _KILL_TAB_PS - Stop-Process -Id $parentPid -Force # trusts a possibly-stale, reused PID + kill parent only if: + - it is currently a shell (cmd/bash/sh/powershell/pwsh), AND + - it is not younger than the claude.exe process it supposedly launched + # a reused PID belonging to WindowsTerminal.exe fails both checks

A recycled PID that now belongs to WindowsTerminal fails both checks: WindowsTerminal isn't a shell, and even if it happened to look like one, a freshly-recycled PID is younger than the agent process it's being blamed for hosting. Either check alone stops the kill.

Proof

The general rule, not just this repo's rule: a terminal multiplexer's tabs are views inside one process, not processes of their own. If your automation ever resolves "which process owns my window" to close it, that resolution can land on the one process every other window shares. Close a tab by sending its own shell an exit, never by killing the PID the window manager reports for it.

Related reading