After a day of Claude Code sessions on my Windows 11 laptop I find dozens of bash.exe, sed, cat, tail, sleep, find, kubectl and git processes still alive. Nobody is waiting for their output any more. The laptop gets slow and memory fills up.
I did not want to wait for an upstream fix, so I wrote a PowerShell script that finds the leftovers and kills only those. It runs every ten minutes from Task Scheduler and again whenever a session ends. This post covers how it decides what to kill, the one surprise that broke my first design, and what it caught in its first days.
What actually happens
On Windows, Claude Code starts its Bash and PowerShell tool through a launcher (bash.exe or cmd.exe) that has no kill-on-close job object around it. When a tool call times out, when I stop a background task, or when the session ends, only the launcher dies. Everything it started keeps running: kubectl, git, helm, find, sleep. Windows does not hand orphans to a new parent, so each leftover simply carries the PID of a parent that no longer exists. I see this on Windows 11 with Claude Code 2.1.287.
This is a known upstream problem. I follow three issues in anthropics/claude-code:
Statuses are as of 2 November 2026, and the log further down shows fresh orphans on every one of the three days it covers. So until this is fixed upstream, the choice is cleaning up by hand or automating the cleanup.
Why "kill everything whose parent is gone" is a bad idea
The obvious reaper kills every process whose parent PID points to nothing. Do not run that. Plenty of legitimate processes on a developer machine outlive their parent: ssh-agent, gpg-agent and SSH tunnels are built to. A rule based only on the parent would one day kill my tunnel in the middle of a deployment.
The reaper needs positive proof that a process belongs to a Claude Code session, not just evidence that it is lonely. So it needs a marker.
The marker, and how to read it
Claude Code sets the CLAUDECODE environment variable in the processes it starts for its tools, and every child inherits it. That is the marker. The catch is that PowerShell has no built-in way to read another process's environment, so the script reads it from the process's memory: NtQueryInformationProcess gives the address of the PEB, the PEB points to RTL_USER_PROCESS_PARAMETERS, and the Environment block is read with ReadProcessMemory. On x64 the offsets are 0x20 (PEB to process parameters), 0x80 (Environment) and 0x3F0 (EnvironmentSize). Process Explorer uses the same technique. I embed it as inline C# through Add-Type, so a scheduled task can run the script without any module installed. The excerpt below is from Reap-ClaudeOrphans.ps1:
IntPtr h = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
if (h == IntPtr.Zero) return null;
try {
PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION();
int ret;
if (NtQueryInformationProcess(h, 0, ref pbi, Marshal.SizeOf(pbi), out ret) != 0) return null;
if (pbi.PebBaseAddress == IntPtr.Zero) return null;
IntPtr prm = ReadPtr(h, (IntPtr)(pbi.PebBaseAddress.ToInt64() + 0x20));
if (prm == IntPtr.Zero) return null;
IntPtr env = ReadPtr(h, (IntPtr)(prm.ToInt64() + 0x80));
if (env == IntPtr.Zero) return null;
long size = ReadPtr(h, (IntPtr)(prm.ToInt64() + 0x3F0)).ToInt64();
if (size <= 0 || size > 8 * 1024 * 1024) return null;
byte[] buf = new byte[size];
IntPtr got;
if (!ReadProcessMemory(h, env, buf, (IntPtr)buf.Length, out got)) return null;
return Encoding.Unicode.GetString(buf, 0, (int)got.ToInt64());
} finally { CloseHandle(h); }
The PowerShell side splits the block into a dictionary and looks for CLAUDECODE. Anything it cannot read (exited, protected, access denied) returns nothing and is skipped. To look at one process by hand, Reap-ClaudeOrphans.ps1 -InspectPid <pid> prints its CLAUDE* variables.
The MSYS surprise
The marker works on the native Windows tools: kubectl, helm, git, node, pwsh, cmd, ping, conhost. It does not work on the processes that make up most of the mess: sleep, sed, cat, tail, find, grep and friends. Those are MSYS binaries from Git for Windows, and the MSYS children of Git Bash receive a stripped Win32 environment of just five variables: MSYSTEM, PATH, SYSTEMDRIVE, SYSTEMROOT and WINDIR. No CLAUDECODE, so the marker is invisible exactly where I need it. I verified this on 2026-10-31, and the log below agrees: 17 of the 25 kills came from the fallback.
The fallback is a second rule. A transient coreutil running from Git's usr\bin with a dead parent is reaped as msys-orphan. It is an allowlist on purpose, so ssh.exe, ssh-agent.exe and gpg-agent.exe, which live in the same directory, survive. The Git location comes from the registry (HKLM:\SOFTWARE\GitForWindows) with C:\Program Files\Git as the fallback.
^(sleep|tail|head|cat|find|grep|egrep|fgrep|sort|uniq|tee|xargs|timeout|watch|less|more|awk|gawk|sed|tr|wc|cut|yes|env|nohup|sh|bash|dash)\.exe$
The rules
A process is reaped only when all of these hold:
- Its parent is gone. Either the parent PID does not exist, or it was reused by a process that is younger than the child, because a parent cannot be created after its own child.
- It is at least 2 minutes old (
-MinAgeMinutes, default 2), so nothing that is still starting up is touched. - It has proof of origin. Either
CLAUDECODEis in its environment (logged asorphan), or it is on the MSYS allowlist and runs from Git'susr\bin(logged asmsys-orphan). - It is not excluded. Neither its name nor its command line matches a line in
orphan-reaper.exclude.txt, one regex per line. The file I ship contains only^claude\.exe$.
Live descendants of a reaped process die with it (logged as child-of-<pid>). The script never touches itself or its own ancestors, because the SessionEnd hook runs as a child of the live claude.exe. My own Git Bash terminals have a live parent (Windows Terminal) and no marker, so they never qualify. Every kill is appended to ~\.claude\logs\orphan-reaper.log with the reason, parent PID, age, working set and the first 300 characters of the command line. With -DryRun it logs would-kill instead and kills nothing.
Running it unattended
Scheduled task. \Claude\Claude Orphan Reaper runs every 10 minutes under my interactive logon. S4U registration, which would let it run without a session, is denied for an Azure AD account, so interactive logon is what is left. The execution limit is 5 minutes and overlapping runs are ignored. A scheduled run writes a log line only when it reaped something, otherwise a run every ten minutes would just be noise.
Under an interactive logon Task Scheduler shows console programs, so a plain pwsh action flashes a window every ten minutes. pwsh -WindowStyle Hidden does not fix that, it still flashes a window before hiding it. The trick is a tiny VBScript started with wscript.exe //B //Nologo. wscript is a GUI-subsystem host and honors windowStyle 0 (SW_HIDE) from the start. The whole launcher is one line that matters:
sh.Run """" & pwsh & """ -NoProfile -NonInteractive -ExecutionPolicy Bypass -File """ & ps1 & """ -Source task", 0, False
It looks for PowerShell\7\pwsh.exe under %ProgramFiles% and falls back to pwsh.exe from PATH. The -Source task argument only labels the log lines.
SessionEnd hook. The same script runs from a SessionEnd hook in ~/.claude/settings.json with a 20 second timeout, so every session ends with a cleanup instead of waiting for the next cycle. Anything younger than 2 minutes waits for the next run. Both the task and the hook are installed by Install-ClaudeOrphanReaper.ps1.
What it did in three days
The log runs from 2026-10-31 07:57 to 2026-11-02 13:16, a bit over two days, three calendar days. Each run scanned between 443 and 548 processes. What it killed:
- 25 kills: 19 by the scheduled task, 6 by the SessionEnd hook. The log also holds 5 error lines (the process was already gone when
Stop-Processran) and 7would-killlines from my dry runs. - By reason: 17
msys-orphan, 5child-of, 3orphan(marker found, all threeconhost.exe). - By name:
sleep.exe5,sh.exe5,bash.exe4,conhost.exe3,timeout.exe2,docker.exe2, and one each ofuniq.exe,nohup.exe,grep.exeandPING.EXE. No ssh or gpg process was among them. - 413 MB of working set in total, 13 to 32 MB per process.
- Oldest orphan at kill time: 7.9 minutes.
Four real lines, command lines truncated, one per reason:
2026-10-31T08:00:40 | task | kill | pid=21248 | conhost.exe | reason=orphan | parent=26348 | age_min=4.4 | ws_mb=15 | \??\C:\WINDOWS\system32\conhost.exe 0x4
2026-10-31T08:02:25 | hook | kill | pid=19040 | nohup.exe | reason=msys-orphan | parent=18704 | age_min=3.2 | ws_mb=14 | "C:\Program Files\Git\usr\bin\nohup.exe" ping -n 200 127.0.0.1
2026-10-31T08:02:25 | hook | kill | pid=26232 | PING.EXE | reason=child-of-19040 | parent=19040 | age_min=3.2 | ws_mb=13 | C:\WINDOWS\system32\ping.exe -n 200 127.0.0.1
2026-11-02T13:01:21 | hook | kill | pid=4784 | bash.exe | reason=msys-orphan | parent=32148 | age_min=7.9 | ws_mb=18 | "C:\Program Files\Git\usr\bin\bash.exe" -c "for i in $(seq 1 12); do s=$(timeout 40 glab api ...
That is not a dramatic number. Each orphan is small, and in my case they are mostly polling loops (sleep, glab api in a while loop, docker exec) that outlived the call that started them. What hurts is that nothing ever cleans them up, and the log only shows what was caught within minutes, so it cannot tell me how big the pile would grow without the reaper.
Limitations and habits
- x64 only. The PEB offsets are hard-coded, and the C# code refuses to run in a non-64-bit PowerShell.
- Not instant. A fresh orphan lives up to one cycle plus the 2-minute minimum age, so roughly 12 minutes in the worst case.
- The MSYS allowlist is narrow. It only covers the coreutils in Git's
usr\bin. - The MSYS rule has no proof of origin. Any allowlisted tool from Git's
usr\binwith a dead parent and an age over 2 minutes is reaped, including anohupjob I started by hand and left running after closing the terminal. If you do that, add a regex to the exclude file. - Unreadable processes are skipped. If the environment block cannot be read, the marker rule cannot match.
Most important: this is a safety net, not a fix for the upstream bug. The habits still matter. Bound every long command with coreutils timeout or the tool's own flags (--request-timeout for kubectl), never run a follow or watch command unbounded, and stop background tasks before ending a session.
Install it in four steps
The code is merged into akuryan/ConfigurationHelpers under ClaudeCode/OrphanReaper (PR #2): Reap-ClaudeOrphans.ps1, Install-ClaudeOrphanReaper.ps1, the VBS launcher, the exclude file and a README with the install, verify and diagnostics steps. The installer copies the three runtime files to ~\.claude\scripts and points the task and the hook at those copies, so the clone can live anywhere. You need 64-bit PowerShell 7. No elevated shell is needed, because the task is registered under your own account. The installer backs up settings.json before it touches it, keeps your other hooks, and is safe to re-run after a git pull.
# 1. Clone
git clone https://github.com/akuryan/ConfigurationHelpers.git
cd ConfigurationHelpers\ClaudeCode\OrphanReaper
# 2. See what the installer would change, change nothing
pwsh -NoProfile -File .\Install-ClaudeOrphanReaper.ps1 -WhatIf
# 3. Install the scheduled task and the SessionEnd hook for real
pwsh -NoProfile -File .\Install-ClaudeOrphanReaper.ps1
# 4. Verify with the installed copy: list what would be killed right now, kill nothing
pwsh -NoProfile -File "$HOME\.claude\scripts\Reap-ClaudeOrphans.ps1" -DryRun -MinAgeMinutes 0
Step 4 always appends a run | scanned=... reaped=... dryrun=True line to the log, so you can see it work even when nothing is orphaned. -Uninstall on the installer reverses step 3. If a legitimate process gets caught, add a regex for it to orphan-reaper.exclude.txt.