Claude Desktop: scheduled-task/background `claude` CLI sessions never exit → memory exhaustion over 1–2 days
Summary
Headless claude CLI sessions spawned by Claude Desktop (macOS) for scheduled tasks / background agents do not terminate after their run completes or hangs. They accumulate indefinitely — one per scheduled fire — each retaining its full plugin + MCP-server stack in RAM. Over ~1–2 days of uptime on a 16 GB machine this exhausts memory and drives the system into a swap death-spiral.
Environment
- Claude Desktop (macOS),
CLAUDE_CODE_ENTRYPOINT=claude-desktop - Bundled Claude Code CLI 2.1.187, Agent SDK 0.3.187
- Apple Silicon (M4), 16 GB RAM, macOS 26.2
Observed impact (real instance)
- 151 leaked
claudeCLI processes + 151Helpers/disclaimerwrappers + ~160 orphaned MCP children (context7-mcp,node, etc.). - Load average ~134 on a 10-core machine — processes blocked on swap I/O, not CPU-bound.
- Swap ~19.8 GB / 20 GB used; ~900M swapins; system-wide free memory ~25%.
- Oldest leaked session alive ~47 h (since previous boot). New leaked sessions appear on a steady cadence matching scheduled-task cron intervals.
- Disk is not involved (plenty free). Purely process/memory accumulation.
- Reboot fully resets it (count → 0, load → ~3), then it rebuilds over 1–2 days (~7 leaked sessions accumulated within 45 min of a fresh boot, with ~28 enabled scheduled tasks).
Repro
- Run Claude Desktop with several recurring scheduled tasks (a sub-hourly one accelerates it).
- Leave the app running 1–2 days without rebooting.
- Observe:
```
ps -Ao comm | sed -E 's|.*/||' | sort | uniq -c | sort -rn | head # hundreds of claude/disclaimer/context7-mcp/node
uptime # load average in the dozens-to-hundreds
sysctl vm.swapusage # swap near-full
claude
Each leaked CLI process traces → Helpers/disclaimer → the Desktop app, launched with --allow-dangerously-skip-permissions --permission-mode auto --disallowedTools AskUserQuestion`, and never exits.
Note for triage
Interactive and background sessions are indistinguishable by command-line flags and by environment (same entrypoint, both carry --allow-dangerously-skip-permissions/--permission-mode, sometimes --resume/--mcp-config), which makes safe external cleanup hard.
Expected
After a scheduled-task / background-agent run completes or times out, the claude CLI process and its MCP child processes should be torn down. Consider capping concurrent background sessions and adding an idle/hung-session timeout.
---
Filed by an affected user. Happy to provide process dumps, logs, or further detail on request.
3 Comments
Independent confirmation on newer builds, plus three details that may help triage.
Desktop app 1.20186.0 with bundled CLI 2.1.205, macOS (Darwin 24.6.0), ~30 scheduled-task runs/day: ~140 leaked
claudeprocesses accumulated over 31 hours (12-core Mac pinned at load 25-40); after a cleanup, it rebuilt from 2 to 18 sessions overnight. Reproduces with hooks fully removed ("hooks": {}), and the leaked CLI processes here had zero MCP children, so the leak does not depend on hooks or MCP teardown. All transcripts end with a completed final assistant message.list_sessions) reportsisRunning: falsefor these sessions while their processes persist. Interactive chat sessions leak identically, not just scheduled runs./bin/zsh -lPTY helper shell, a direct child of the app, which survives even after the session'sdisclaimer+claudepair is killed. It ignores SIGTERM and needs SIGKILL; the app logs its exit benignly ("Shell PTY for session ... exited with code 0").claudeprocess makes the app logquery error: Claude Code process terminated by signal SIGKILLand pop a "Blocked by endpoint security" dialog advising the user to allowlist Team ID Q6L2SF6YDW, even with no endpoint security installed. SIGTERM avoids it: the pair exits within seconds, logged asexited with code 143, no dialog, and the session still resumes cleanly in the app afterward.Re the triage note about safe external cleanup being hard: an hourly launchd job that SIGTERMs any matching process older than 6 hours (then SIGKILLs survivors after a 15s grace) has been a workable stopgap here. Age is the only usable discriminator, and since externally killed sessions resume cleanly in the app, the worst case is interrupting a very long interactive session mid-turn, not losing it.
Corroborating data point, same failure mode as this issue (and closely related to #80885):
Environment: macOS 26.6, Claude Desktop, Claude Code binary 2.1.222, Apple Silicon, 16 GB RAM, 10-core.
Observed:
claudeCLI processes found alive under.../Claude/claude-code/2.1.222/claude.app/Contents/MacOS/claude, together consuming ~396% CPU and ~3.1 GB RSS.list_sessionsreturned 63 non-archived sessions, spread across the current day plus a few from the prior few days — every single one reportedisRunning: false, yet the OS-level processes were still alive and actively burning CPU. Same isRunning-false-but-alive mismatch as the parent issue.Notably these weren't obviously all scheduled-task fires — some appear to be regular sessions that were closed/backgrounded in the UI during the day. So the leak doesn't seem strictly limited to the scheduled-task path; the common thread across all of them seems to be the session registry marking a session done/backgrounded without the underlying OS process actually being torn down.
Happy to provide the raw
ps/vm_statoutput if useful for triage.Still present on Claude Code 2.1.247 / Claude Desktop 1.40609.0 (macOS 26.6.2, arm64, 14 cores, 36 GB). Adding a data point plus what looks like direct evidence of the root cause, which I think narrows down the speculation in #71424.
The leak: stdin is never closed
Every leaked session has fd 0 still open as a unix socket:
The session runs with
--input-format stream-jsonand blocks reading stdin. The Desktop app never closes that socket and never sends a shutdown, so the child never sees EOF and the read never returns. It is not a hung event loop or an unref'd timer, it is a legitimate blocking read on a pipe the parent left open. Process state isSthroughout.This also suggests the fix is cheap on the app side: close the child's stdin at session teardown and the existing read path should unwind on its own.
These leaks are not idle
Both issues focus on memory. The leaked sessions also burn CPU continuously:
Snapshot before/after cleanup
86 leaked sessions accumulated in 12h51m of app uptime, one per session, none exited.
| | before | after killing 424 leaked PIDs |
|---|---|---|
| Load average | 104 | 25 |
| CPU idle | 0.2% (77% sys) | ~10% (74% user) |
| PhysMem used / free | 35G / 210M | 21G / 14G |
| Compressor | 13G | 3.7G |
| Swap | 11.4G / 12G | 2.4G / 4G |
| Processes / claude sessions | 1080 / 87 | 620 / 2 |
kernel_taskwas at 162%, and pages-decompressed had reached 3.3 billion. The machine was not CPU-bound, it was thrashing: ~25 GB was held by leaked sessions and their MCP children (~4 MCP servers per session, 344 helper processes totalling 11.9 GB).Two notes for anyone writing cleanup tooling
1.
/tmp/cc-socks/<pid>.sockalso leaks. 89 socket files for 87 live sessions. Same lifecycle problem, easy to miss.2. Killing a leaked session can kill live work. This is a sharper version of the "interactive and background sessions are indistinguishable" note in #72308. Long-running jobs a session started (compilers, test runners, dev servers) are descendants of that session's process. A leaked session that has been idle for six hours can still own a build that started two minutes ago. I killed a 54-minute compile and a static-analysis run this way, because I checked whether the session was busy rather than whether its subtree was.
Any external reaper needs to walk the descendant tree and skip a session if any descendant is active, and even that races. Which is really an argument for fixing it in the app rather than papering over it with a reaper.
The app already records the discriminator it says it lacks
The "interactive and background sessions are indistinguishable" note is true of the process table, but not of the Desktop app's own state. Each session's record under
~/Library/Application Support/Claude/claude-code-sessions/**/local_<uuid>.jsoncarries a top-levelscheduledTaskId, set for scheduled-routine runs and absent for human conversations:cliSessionIdin the same record maps to thesessionIdin~/.claude/sessions/<pid>.json, which gives a clean pid → class mapping.list_sessionsalso exposes anisRunningflag that is correct in practice, though it appears to be computed at runtime rather than persisted.This matters because idle time alone is not a safe signal, and I learned that the hard way. My first reaper used only "transcript untouched for N minutes" and, on its first real pass, flagged the live conversation I was using to write it. I had simply been away for six hours. A walked-away chat and a leaked session are byte-for-byte alike: same never-closed stdin, same self-held
cc-socksentry, same child count, same idle CPU.Gating on
scheduledTaskIdfixed it. On a machine with 51 live sessions, 47 were leaked scheduled-routine runs and 4 were human chats; the gate reaped 30 routine leaks across two passes and left all four conversations untouched. So a safe external reaper is possible today, but only because that field happens to be on disk, which is a fairly strong hint that the app has everything it needs to close these itself at teardown.Workaround
Quit and reopen the Desktop app. Every leaked session is a child of the main
Claudeprocess, so they all get reaped at once.