Claude Desktop local-agent (Cowork) sessions leak claude-code node processes (never exit at session end) — GBs of RAM + MCP port exhaustion
Summary
On macOS, local Claude Code sessions launched by the Claude Desktop app (the "Cowork" / local-agent mode, where the Desktop app spawns claude-code/<version>/claude.app/Contents/MacOS/claude node processes) are not reaped when the session ends. The node process keeps running indefinitely after the conversation is over, and it keeps its child MCP-server processes alive with it. Over hours/days these accumulate into dozens of multi-hundred-MB processes, and the surviving MCP children keep holding the OS resources they own — notably TCP ports — which eventually breaks new sessions.
Environment
- Claude Desktop (macOS) launching local Claude Code sessions and scheduled tasks
- Claude Code version 2.1.181
- Per-session process lineage:
/Applications/Claude.app/Contents/MacOS/Claude→…/Contents/Helpers/disclaimer …→…/claude-code/<ver>/claude.app/Contents/MacOS/claude(the node) → MCP-server children
Observed behavior
- Every local session (and every scheduled-task run) spawns a
claude-codenode process under the Desktop app. - When the session/conversation ends, that node process does not exit. SessionEnd hooks do fire (so the session logically ends), but the process keeps running.
- Lingering nodes accumulate without bound until the Desktop app is restarted. Observed dozens at once (50+), including processes from sessions started 1–3 days earlier still running. In a fresh snapshot after a restart: ~5 nodes from a single morning's scheduled-task burst, each ~230–380 MB RSS (~1.2 GB total); the count climbs as more sessions run.
- The lingering node keeps its child MCP servers alive too. Concretely, a stdio MCP server it spawned (in our case the Google Workspace MCP,
workspace-mcp) keeps running and keeps holding the local TCP port it bound (we saw 17workspace-mcpprocesses alive in one snapshot). - The per-session registry files at
~/.claude/sessions/<node_pid>.jsonare also not cleaned up — they persist after the session ends and carry no heartbeat (mtime is frozen at session start), so they can't be used to tell a live session from a leaked one.
Impact
- Unbounded memory leak: dozens of multi-hundred-MB node processes (>1 GB quickly) plus their MCP-server children, until the Desktop app is restarted.
- Concrete downstream failure (how we found it): each leaked node's
workspace-mcpchild keeps holding its OAuth-callback TCP port.workspace-mcpbinds within a small fixed port range per account (e.g. 8001–8005). Once leaked instances fill the range, a new session's Google Workspace MCP can't bind and exits withNo available port in range→ the connector shows "Failed to connect." This presents as an intermittent, confusing auth-looking failure that is actually port exhaustion caused by the process leak. - The only remediation is quitting and reopening the Desktop app.
Steps to reproduce
- On macOS, use Claude Desktop's local-agent / Cowork mode (and/or scheduled tasks) to run several Claude Code sessions over time. Have each connect at least one stdio MCP server that binds a local socket (e.g. a Google Workspace MCP).
- End each session normally.
ps -ax | grep 'claude-code/.*MacOS/claude'— node processes from ended sessions remain running indefinitely; their MCP-server children remain too. The count grows with each session and never decreases until the Desktop app restarts.
Expected behavior
When a local session ends, its claude-code node process and the MCP-server children it spawned should be terminated, and its ~/.claude/sessions/<pid>.json cleaned up.
Notes / suggested investigation
- Node process not exiting at session teardown (an open libuv handle / unref'd timer / lingering socket keeping the event loop alive?).
- MCP child processes not being killed when their owning session ends (and, relatedly, stdio MCP servers that don't exit on stdin EOF can outlive even a dead parent).
- The session-registry file lacking an end marker / heartbeat leaves external cleanup tooling unable to distinguish active vs ended sessions.
Severity: Medium-high — silent multi-GB memory growth on long-running Desktop installs, plus intermittent MCP failures that look like auth bugs.
4 Comments
Corroborating this on a newer version: it still reproduces on Claude Code 2.1.187 (this issue was filed on 2.1.181), macOS, sessions launched by Claude Desktop's scheduled tasks.
Setup: one hourly scheduled task. Every fire leaves exactly one
…/claude-code/<ver>/claude.app/Contents/MacOS/claude --output-format stream-jsonagent process that never exits. Clean one-per-fire fingerprint over 8 hours (paths/user redacted):Each process is ~390 MB RSS and accumulates only ~20 s of CPU per hour — i.e. it is idle/sleeping after its work finished, not still working. The task's actual workload is plain Python subprocesses that exit cleanly on every run; only the wrapping
claudeagent session lingers.Prior severe impact on the same machine, before I started watching for it: these accumulated to 214 sessions → load ~96 on a 10-core M4, and the box overheated. The only thing that cleared them was quitting/reopening Claude Desktop — which is consistent with "restart reaps the children" and with the OP's findings.
Detection note that may help others: count the agent sessions with
A naive
grep -i claudeonly counts the stable Claude Desktop Electron app tree (~11–13 processes) and is blind to these leaked agent sessions.The leaked sessions had MCP tools enabled (computer-use plus a couple of stdio MCP servers), which fits the "an MCP child keeps the event loop alive / doesn't exit on stdin EOF" hypothesis raised in the issue.
Corroborating this from a third, independent trigger path — not Cowork, not scheduled tasks. Plain cmd-click / "Open in → New Window" local-agent-mode sessions (the mechanism in #63016) leak identically, so this isn't Cowork-specific code — it's shared local-agent-mode session-teardown machinery.
area:coworkundersells the scope.Setup: macOS, Claude Code 2.1.219, Claude Desktop. No Cowork feature touched, no scheduled tasks. Sessions opened by cmd-clicking to spawn parallel windows over the course of a day (2026-07-28 into 2026-07-29), each with a user-level MCP server config (
~/.claude.json) providing several stdio MCP servers, including one that shells out to a local socket-based bridge process (freecad-mcp, a third-party FreeCAD control server).Evidence this isn't an error/crash-path artifact: I picked a session at random and read its full transcript. It ran a normal diagnostic task, reached a complete, correct resolution, and ended with an ordinary final assistant message — no exception, no hang, no crash on Claude's side:
Cross-referencing against
psand the per-session registry file at~/.claude/sessions/<pid>.json(which recordssessionId,cwd,procStart, giving an authoritative PID↔session mapping rather than a timestamp guess), the session's ownclaudenode process spawned its MCP bridge child 1 second before the session's first message — and as of this writing, hours after the conversation's clean natural end, that bridge process (and its parentclaudenode) are both still running, still in a normal idle sleep state, no crash, no zombie/defunct state. A second session ("Weekly FC review and version build") shows the identical pattern: bridge spawned 1 second before the session's first message, still alive long after the session's last message.(Note: I couldn't find any field — in the process command line or in the
~/.claude/sessions/<pid>.jsonregistry, which is identical in shape for every session regardless of how its window was opened — that records how a given session's window was created. So I can't independently verify from logs alone that these were specifically cmd-click-spawned rather than some other multi-window mechanism; that's from the reporter's own recollection of their workflow that day, not something the logs confirm one way or the other.)Confirms the OP's
isRunning: false-but-alive finding, with the socket-holder identified:list_sessions(the app's own session-tracking) reports both of these asisRunning: false.lsofshows the file descriptor on the other end of each leakedclaudeprocess's stdin/stdout socket is still held open by/Applications/Claude.app/Contents/MacOS/Claude— the main Electron process itself, not some intermediate helper. So this isn't a case of a child process holding a stray fd open; the app's own top-level process never released its connection to the session when it updated the session list to say the session had ended.Scale, for reference: across roughly one day of intermittent multi-window usage, I found 7 leaked
claudenode processes (confirmed against~/.claude/sessions/<pid>.jsonregistry files and cross-checked asisRunning: falsevia the session-list API — one additional process I initially miscounted turned out to be a still-live, legitimately-running session, not a leak), each with its own leaked MCP bridge child, none reaped. All 7 mount the same shared local-agent-mode plugin directory.Downstream effect worth flagging for anyone chasing a similar report: because MCP server discovery/config in my case is user-level, every one of these 8 leaked sessions independently holds its own connection-capable bridge process to the same external tool (a single FreeCAD instance, in my case globally discoverable via a
~/.cacheregistry). A live, current session's tool calls against that same external resource can then contend with however many leaked sessions' bridges are still sitting around from days prior — producing confusing "operation in flight, never returns" symptoms in an otherwise-unrelated live session, with no obvious link back to the real cause (leaked sessions from unrelated past conversations) unless you go looking for it viaps/lsofthe way this thread did. If anyone else is debugging a mysterious hang against a shared local tool/service and lands here from search: check for leaked sessions before assuming the tool itself is at fault.Happy to provide the full
ps/lsofoutput orlist_sessionsJSON if useful for triage.Possibly worth consolidating: #69854 and #77940 both look like the same underlying defect (session-teardown not reaping the node process / not releasing its connection) surfacing through yet more trigger paths — scheduled tasks and background "bg" sessions respectively. #80885 also matches the
isRunning: false-but-alive symptom directly. Between the four of us we've now got four independent trigger paths (Cowork, scheduled tasks, background sessions, cmd-click) all hitting what reads like one shared teardown bug in local-agent-mode session lifecycle management — might be worth relabeling this one beyondarea:coworkand treating it as the canonical thread, if that's useful for triage.Distinguishing this from the older, broader MCP-cleanup issues: #1935 and #79567 also describe MCP server processes surviving session end, and given #1935's age (filed June 2025) and comment count it's an easy target to fold new reports into by default. But both of those describe plain CLI session exit (
exit, closing a terminal tab) — a different code path than what's going on here. This report and #69854/#77940/#80885 are specifically about the Desktop app's interactive/local-agent-mode session lifecycle: the app's own bookkeeping (isRunning) flips to false while the app's own top-level Electron process (not just some MCP child) still holds a live, connected IPC socket to the "ended" session. That's a different failure than "the CLI didn't SIGTERM its MCP children on exit" — here the CLI process itself never even got the signal to exit, because the thing that was supposed to close its connection didn't. Worth keeping this thread separate from #1935/#79567 even if they turn out to share a root cause, since the repro and the actual defective code path aren't the same.Follow-up on my comment above, on why this went from presumably-present-but-unnoticed to hitting three times in one hour for me: recording the reproduction conditions I was able to pin down, in case they're useful to anyone trying to reproduce this deliberately.
A leaked session is silent by itself — idle at ~0% CPU, no crash, nothing to notice — until enough of them accumulate and one happens to collide with something live on a resource it's still holding open (in my case, a shared local MCP server instance). So severity/visibility should scale with two things: how long since my last full app quit (nothing gets reaped until then), and how many session-spawn events happened in that window.
Two things I was able to check directly, not just estimate:
ps, not guessed.The piece I can't independently verify from any log I have access to, though I can verify a closely related fact that sharpens it: running multiple concurrent sessions is not new behavior for me -- my own conversation history shows real overlapping multi-session activity going back months, well before this incident, so the total amount of parallelism isn't the variable that changed. What I believe changed, based on my own recollection of how I've been working rather than anything logged: in the 2-3 days before this incident I shifted a lot of that routine parallelism from separate Desktop app processes (launched fresh from separate shells) over to local-agent-mode/cmd-click spawning within a single already-running app instance. No field I could find anywhere (session registry, conversation metadata, raw transcript JSON) records how a given session's window was opened, so which mechanism produced any given past session isn't something I can confirm from logs -- this part is my own firsthand read on a recent change in my usage pattern, not measured. Flagging that distinction explicitly rather than presenting it as harder evidence than it is. But it matters mechanistically: separate app processes are independent process trees that get torn down cleanly by the OS when each one quits, while cmd-click sessions all share the one long-lived Electron process this bug lives in. If that shift is real, it would concentrate months of routine parallelism onto the one code path that doesn't reap its children, which is a more precise candidate trigger than "more parallel sessions" would be. Combined with the two confirmed points above, it's a coherent hypothesis for why this surfaced now, rather than being a mystery stacked on top of an already-known bug.
Corroborating this on Windows, and on much newer versions. The issue is labeled
platform:macos, but the same leak reproduces on Windows 10 — and I have a diagnostic detail that may point at what holds the process open.Environment
entrypoint: claude-desktop)5-55/10 * * * *(every 10 minutes).Observed
Same as the original report: every scheduled-task run spawns a
claude.exechild of the Electron host, and that child never exits after the run completes. It accumulated to 49 processes holding 21 GB of commit charge out of a 40 GB commit limit, leaving 156 MB free — at which point the machine could no longer allocate anything:claude --versiondied with a Bun stack overflowConvertFrom-JsonthrewOutOfMemoryExceptionon an 8 KB fileRoughly 470–550 MB commit per leaked process, ~6 runs/hour ≈ 4 GB/hour. Restarting the Desktop app is what clears it, matching the report.
Possible holder: the process's own HTTPS keep-alive pool
A leaked process whose run has demonstrably finished (transcript frozen, final report written) still holds established connections to the API:
Sampled 170 seconds apart: transcript growth 0 lines, CPU +0.92 s, sockets still Established.
The control group is what makes this sharp. On one day, 58 of 91 scheduled runs died early on
API Error: Unable to connect to API (ENOTFOUND)during two network outages. Every one of those exited cleanly. The 33 runs that did real work all leaked. So: work => leak, no work => clean exit.The early-failing runs never resolved DNS, so they never established the API connection pool and never spawned MCP children. That suggests the parent's own undrained agent keep-alive connections are sufficient on their own to hold the process open — i.e. a mechanism independent of (and additional to) the MCP-child-holding-a-port mechanism described in the original report.
One correction, offered constructively
The report states that the
~/.claude/sessions/<pid>.jsonfiles "carry no heartbeat (mtime is frozen at session start), so they can't be used to tell a live session from a leaked one." They can, if you combine them with the transcript:sessions/<PID>.jsongives an authoritativepid -> sessionIdmap, plusstartedAt— validate that against the live process start time to guard against PID reuse.<sessionId>.jsonl. Its head contains a<scheduled-task name=\"...\">marker for scheduled runs, which distinguishes them from interactive sessions. (Note the quotes are JSON-escaped inside the JSONL, so a naivename="..."regex misses them.)Gating on all of it together — scheduled-task marker, transcript quiet >= 10 min, process age >= 30 min, and a live CPU sample near zero — reaps leaked runs reliably without ever touching a live interactive session.
One warning for anyone else building a workaround: matching processes to transcripts by creation time is not safe. I tried that first, and a dry run showed it mapping my live interactive session to a scheduled run's transcript that had been created 5 seconds earlier — it would have killed the active conversation. The
sessions/<PID>.jsonmap removes the guesswork.