[Bug] Scheduled-task routines on macOS leak orphan claude subprocesses (kevent64 stall on stdin)
What's wrong
When the macOS Claude.app desktop app spawns a one-shot claude CLI subprocess for a scheduled-task routine (under ~/.claude/scheduled-tasks/<routine>/), the subprocess never exits after the skill completes. The Stop hook: Query completed log line fires, but the claude process stays alive indefinitely, blocked in kevent64 waiting on its stdin (a unix-domain-socket pipe to its disclaimer wrapper parent, which nothing is ever going to write to again).
One orphan accumulates per cron fire. With a routine firing every 15 minutes, ~96/day. Over ~26 hours of accumulation I had 234 orphans consuming ~20 GB RAM, ~7.9 GB swap, with system load average 163 — the desktop UI became unusable until I manually killed them.
Expected behavior
When the skill stop hook fires for a non-interactive routine session (started with --replay-user-messages --disallowedTools AskUserQuestion), the claude subprocess should exit cleanly. Either:
- The
claudebinary should detect "no more queued user messages + stop hook fired + non-interactive flags" and exit on its own, or - The disclaimer wrapper / desktop harness should close the child's stdin (EOF) or SIGTERM the child after the skill completes.
Steps to reproduce
- Set up any scheduled-task routine that fires periodically — e.g. a
~/.claude/scheduled-tasks/<name>/directory configured to run every 15 min via the desktop app's scheduling. - Leave Claude.app running so it can fire the routine.
- After each fire, the skill completes successfully (log line
Stop hook: Query completedappears in~/Library/Logs/Claude/...). - Observe orphan accumulation:
````
pgrep -f 'claude.app/Contents/MacOS/claude' | wc -l
Count grows by exactly one per cron fire. Number never decreases on its own.
- Inspect any orphan:
ps,lsof, andsampleshow the chain described below.
Spawn chain (deadlock)
Claude.app (PID N, desktop app — alive, healthy)
└─ Helpers/disclaimer (PID M, blocked in __wait4 on its child)
└─ ...claude.app/Contents/MacOS/claude
--output-format stream-json --input-format stream-json
--replay-user-messages --disallowedTools AskUserQuestion ...
(PID M+1, blocked in kevent64 on fd 0/stdin)
disclaimer is a wait4-only parent with no event loop, so it won't write to claude's stdin. claude is in stream-json input mode waiting on stdin for the next user message, which never arrives. The skill's stop hook fires, but nothing translates that into "send EOF on stdin" or "SIGTERM the child."
SIGTERM to the orphan claude PID interrupts the kevent64 wait and the process exits cleanly (and the disclaimer immediately exits when its child reaps), so this is not an unkillable-D-state hang — just a missing signal source.
Diagnostic evidence (captured live)
ps -fp <orphan>:
UID PID PPID STIME CMD
501 57240 57239 2:02PM /Users/.../claude.app/Contents/MacOS/claude --output-format stream-json --verbose --input-format stream-json --model default --permission-prompt-tool stdio --allowedTools mcp__... --disallowedTools AskUserQuestion --setting-sources=user,project,local --permission-mode bypassPermissions --allow-dangerously-skip-permissions --include-partial-messages --plugin-dir ... --replay-user-messages --settings {}
501 57239 44268 2:02PM /Applications/Claude.app/Contents/Helpers/disclaimer .../claude.app/Contents/MacOS/claude ...
501 44268 1 Sat10AM /Applications/Claude.app/Contents/MacOS/Claude
lsof -p 57240 -a -d 0,1,2 — stdio bound to anonymous unix-domain sockets, peer is the live disclaimer:
COMMAND PID FD TYPE DEVICE NAME
claude 57240 0u unix 0x71302d6d7828758f ->0x3e7ffe1aefbead56
claude 57240 1u unix 0xb806ee172db2f101 ->0x32007e26c34cbde6
claude 57240 2u unix 0x9cf0a3d666017603 ->0x288767784f690c89
sample 57240 1 — main thread parked in kevent64:
883 Thread_xxx DispatchQueue_1: com.apple.main-thread (serial)
883 start (in dyld) + 6992
883 (in claude binary) ... event loop ...
883 kevent64 (in libsystem_kernel.dylib) + 8
sample 57239 1 — disclaimer parent blocked in __wait4 on the claude child (so it can never reap it on its own):
883 Thread_xxx DispatchQueue_1: com.apple.main-thread
883 start (in dyld) + 6992
883 main (in disclaimer) + 480
883 __wait4 (in libsystem_kernel.dylib) + 8
Environment
- macOS: 26.4.1 (build 25E253), Darwin kernel 25.4.0, Apple Silicon (arm64)
- Claude.app desktop: 1.6608.2 (CFBundleVersion 1.6608.2)
- Bundled
claudeCLI (the binary that hangs): 2.1.128, bundled inside the app under~/Library/Application Support/Claude/claude-code/2.1.128/claude.app/Contents/MacOS/claude - System
claude --version: 2.1.138 — not used by the desktop spawn path; included for completeness - Reproducible at every cron fire over multiple days.
Discriminator: which processes are affected vs not
| Process kind | --replay-user-messages | --disallowedTools AskUserQuestion | Affected? |
|---|---|---|---|
| Routine sessions spawned by Claude.app's scheduler | ✅ | ✅ | yes — these orphan |
| Interactive Claude.app sessions | ✅ | ❌ | no |
| claude --channels keep-alives | ❌ | ❌ | no |
So the bug is specific to the non-interactive scheduled-task spawn path in the desktop app, not the general CLI.
Workaround (stopgap)
A launchd agent that runs every 10 min, finds claude processes matching all of:
- argv contains
--replay-user-messages - argv contains
--disallowedTools AskUserQuestion - argv[0] matches
claude.app/Contents/MacOS/claude - process age > 15 min
…and SIGTERMs them (and their disclaimer wrappers, after path-validating). The discriminator is exclusive, so interactive sessions and claude --channels keep-alives are never touched. SIGTERM is enough — kevent64 wait is signal-interruptible.
<details>
<summary>Reaper script — <code>~/.local/bin/claude-orphan-reaper</code></summary>
#!/usr/bin/env bash
# Reap orphan local-agent-mode `claude` subprocesses spawned by Claude.app's
# scheduled-task routines and stuck in kevent64 on stdio sockets to a
# wait4-blocked `disclaimer` parent.
set -euo pipefail
THRESHOLD=${CLAUDE_REAPER_THRESHOLD:-900} # 15 min — never touch fresh spawns
GRACE=${CLAUDE_REAPER_GRACE:-10} # SIGTERM, wait, then SIGKILL
LOG="$HOME/Library/Logs/claude-reaper.log"
ts() { /bin/date -u +%Y-%m-%dT%H:%M:%SZ; }
DESKTOP_PID=$(/usr/bin/pgrep -x -n Claude 2>/dev/null || true)
candidates=$(/bin/ps -axo pid,ppid,etime,command \
| /usr/bin/awk -v t="$THRESHOLD" '
function s(x, n,a){ n=split(x,a,/[-:]/);
if(n==4) return a[1]*86400 + a[2]*3600 + a[3]*60 + a[4];
if(n==3) return a[1]*3600 + a[2]*60 + a[3];
if(n==2) return a[1]*60 + a[2];
return a[1]+0 }
/--replay-user-messages/ \
&& /--disallowedTools AskUserQuestion/ \
&& /claude\.app\/Contents\/MacOS\/claude/ \
&& s($3) > t { print $1, $2, s($3) }')
[[ -z "$candidates" ]] && exit 0
claude_pids=(); disclaimer_pids=(); oldest_pid=""; oldest_age=0
while IFS=' ' read -r pid ppid age; do
[[ -z "$pid" || -z "$ppid" ]] && continue
ppid_cmd=$(/bin/ps -o command= -p "$ppid" 2>/dev/null || true)
ppid_ppid=$(/bin/ps -o ppid= -p "$ppid" 2>/dev/null | /usr/bin/tr -d ' ' || true)
if [[ "$ppid_cmd" == *"/Applications/Claude.app/Contents/Helpers/disclaimer"* ]]; then
if [[ -n "$DESKTOP_PID" && "$ppid_ppid" == "$DESKTOP_PID" ]] \
|| [[ -z "$DESKTOP_PID" && "$ppid_ppid" == "1" ]]; then
disclaimer_pids+=("$ppid")
fi
fi
claude_pids+=("$pid")
if (( age > oldest_age )); then oldest_age=$age; oldest_pid=$pid; fi
done <<< "$candidates"
[[ ${#claude_pids[@]} -eq 0 ]] && exit 0
for pid in "${claude_pids[@]}" "${disclaimer_pids[@]}"; do
/bin/kill -TERM "$pid" 2>/dev/null || true
done
/bin/sleep "$GRACE"
kill_count=0
for pid in "${claude_pids[@]}" "${disclaimer_pids[@]}"; do
if /bin/kill -0 "$pid" 2>/dev/null; then
/bin/kill -KILL "$pid" 2>/dev/null || true
kill_count=$((kill_count + 1))
fi
done
echo "[$(ts)] term=${#claude_pids[@]} kill=${kill_count} disclaimer=${#disclaimer_pids[@]} oldest_pid=${oldest_pid} oldest_age_s=${oldest_age}" >> "$LOG"
</details>
<details>
<summary>launchd plist — <code>~/Library/LaunchAgents/com.example.claude-reaper.plist</code></summary>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.example.claude-reaper</string>
<key>ProgramArguments</key>
<array><string>/Users/YOU/.local/bin/claude-orphan-reaper</string></array>
<key>StartInterval</key> <integer>600</integer>
<key>RunAtLoad</key> <true/>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key> <string>/Users/YOU</string>
<key>PATH</key> <string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>StandardOutPath</key> <string>/Users/YOU/Library/Logs/claude-reaper.out.log</string>
<key>StandardErrorPath</key><string>/Users/YOU/Library/Logs/claude-reaper.err.log</string>
</dict>
</plist>
Install: chmod +x ~/.local/bin/claude-orphan-reaper && launchctl load -w ~/Library/LaunchAgents/com.example.claude-reaper.plist
</details>
This is reactive cleanup, not a fix. The real fix should be in the desktop app's scheduled-task spawn path — either close stdin on the child after the stop hook fires, or have the bundled claude CLI exit on its own when it's in --replay-user-messages --disallowedTools AskUserQuestion mode and the queued message stream is exhausted.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗