SLOPSHOPPER

session-reaper

Frees the memory of idle Claude Code desktop sessions by stopping their process trees (the sessions stay in the sidebar), optionally archives them, and stops…

newprocesstimer
★ 1v?MITupdated 2026-10-15IT-BAER/claude-forge/mods/session-reaper
A shopper browsing a rack in a slop shop
README

session-reaper

A Claude Code mod for the Claude desktop app on Windows. Every few minutes it:

  1. Releases Code sessions that had no activity for longer than a limit (default 60 minutes): it stops the session's own process tree (claude.exe, its MCP servers, shells and helpers). The session stays in the sidebar, keeps its conversation, and the app starts a fresh process when you open it again and send a message. Running sessions, and sessions whose side session is still working, are skipped. Pinned sessions are released like any other.
  2. Optionally archives sessions idle for longer than a second limit (archiveMinutes, off by default). Running and pinned sessions are skipped; the app itself also refuses sessions that are open on screen or still have background work. Archived sessions can be restored from the Archived list.
  3. Stops process trees that a session left behind. It records every process a claude.exe started (MCP servers, dev servers, shells). Once that claude.exe is gone, a tree is stopped only when every process in it is at least 60 minutes old and used almost no CPU since the previous check. chrome.exe, msedge.exe, Code.exe, claude.exe, explorer.exe and WindowsTerminal.exe are never stopped (a released session's own claude.exe is the one exception).

Every open session loads the mod. Each one reports its own claude.exe to the plugin store once, so the sessions can be mapped to their processes; only one session does the work (a heartbeat in the plugin store picks it).

Why it exists

Each desktop Code session keeps its own claude.exe (about 0.4 GB) plus its own copy of every MCP server, about 1.6 to 3.2 GB per session in all, and none of it goes away while the session sits in the sidebar. On a 32 GB machine, a day of work left 38 claude.exe (15 GB) and 88 node.exe (8 GB) processes running, the commit charge reached 47.1 of 47.6 GB, and Chrome crashed with an out-of-memory error. Releasing the idle sessions frees that memory without losing the sessions.

Requirements

  • Windows installed in C:\Windows (the process steps run C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe by its fixed path and read Win32_Process).
  • The Claude desktop app's Code tab. The release and archive steps call the app's ccd_session_mgmt server, which other hosts (CLI, VS Code) do not have; there only the orphan step runs.
  • Remote Control: a released session is no longer served to claude.ai/code until it is opened again on the computer.
  • In auto permission mode the archive call from a mod is refused unless it is allowed. If you turn archiving on, add this rule to permissions.allow in ~/.claude/settings.json:
  "mcp__ccd_session_mgmt__archive_session"

The rule also lets Claude archive sessions in any chat without asking.

Install

/plugin install session-reaper --marketplace IT-BAER/claude-forge

Options

OptionDefaultMeaning
idleMinutes60Release a session idle for this many minutes (stop its process tree, keep the session)
archiveMinutes0Also archive a session idle for this many minutes; 0 = never
orphanMinutes60Minimum age of every process in a left-behind tree
cpuIdleMs1000Most CPU time (ms) a process may use between two checks and still count as idle
intervalMinutes5How often the active session checks

Only sessions that loaded the mod can be released, and only processes seen under a running claude.exe while the mod runs are tracked as orphans; trees left behind before the mod loaded are not touched.

Source 1 files
hooks/register.ts 195 lines
1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3type Row = { pid: number; ppid: number; name: string; created: string; cpu: number }
4type Proc = { pid: number; created: string }
5type Tracked = Record<string, { cpu: number }>
6// The app's session id -> the claude.exe that session runs in, reported by that session's own mod instance.
7type Owned = Record<string, Proc>
8type Session = { sessionId: string; isArchived?: boolean; isRunning?: boolean; pinned?: boolean; lastActivityAt?: string; startedBy?: string; title?: string }
9
10const MIN = 60_000
11const SERVER = 'ccd_session_mgmt'
12// Never stopped: shared browsers, editors, the app itself and the shell.
13const SPARED = new Set(['chrome.exe', 'msedge.exe', 'code.exe', 'claude.exe', 'explorer.exe', 'windowsterminal.exe'])
14// A released session loses its own claude.exe (the session process and its claude-in-chrome helper).
15const SPARED_IN_SESSION = new Set([...SPARED].filter(n => n !== 'claude.exe'))
16
17const key = (r: Proc) => `${r.pid}:${r.created}`
18// procs.ps1 prints creation as FILETIME (100 ns since 1601); dropping 4 digits keeps the number exact.
19const createdMs = (created: string) => Number(created.slice(0, -4)) - 11_644_473_600_000
20
21type Cfg = { idleMs: number; archiveMs: number; orphanMs: number; cpuIdle: number; intervalMs: number }
22
23function log($: Engine, text: string) {
24  return $.ui.log(`session-reaper: ${text}`, { to: 'debug' })
25}
26
27// Fixed exe path (not SystemRoot, which a project's settings env can set) and the plugin folder as cwd.
28// shortcut: assumes Windows in C:\Windows; elsewhere the process steps fail and only archiving runs.
29const POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
30
31function procs($: Engine, args: string[]) {
32  const script = `${$.plugin.root}/hooks/procs.ps1`
33  return $.process.run([POWERSHELL, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, ...args], { cwd: $.plugin.root, timeoutMs: 60_000 })
34}
35
36function text(res: { isError?: boolean; content: readonly unknown[] }) {
37  const t = res.content.find(c => (c as { type: string }).type === 'text') as { text: string } | undefined
38  return res.isError || !t ? undefined : t.text
39}
40
41// shortcut: last writer wins on a simultaneous takeover; harmless, both act on the same state.
42async function isLeader($: Engine, now: number, cfg: Cfg) {
43  const me = await $.session.id()
44  const lead = (await $.store.get('leader')) as { id: string; at: number } | undefined
45  if (lead && lead.id !== me && now - lead.at < 2.5 * cfg.intervalMs) return false
46  await $.store.set('leader', { id: me, at: now })
47  return true
48}
49
50// Each session publishes its own claude.exe once; the leader needs it to release the session later.
51type Self = { sessionId: string; proc: Proc }
52async function reportSelf($: Engine, self: { v?: Self }) {
53  if (!self.v) {
54    const got = text(await $.mcp.call(SERVER, 'get_session', { session_id: 'self' }))
55    const who = await procs($, ['-Self'])
56    if (!got || who.exitCode !== 0) return log($, 'self report failed')
57    self.v = { sessionId: (JSON.parse(got) as Session).sessionId, proc: JSON.parse(who.stdout) as Proc }
58  }
59  const owned = ((await $.store.get('owned')) ?? {}) as Owned
60  const have = owned[self.v.sessionId]
61  if (have && have.pid === self.v.proc.pid && have.created === self.v.proc.created) return
62  owned[self.v.sessionId] = self.v.proc
63  await $.store.set('owned', owned)
64}
65
66async function listSessions($: Engine) {
67  const got = text(await $.mcp.call(SERVER, 'list_sessions', { limit: 200 }))
68  if (!got) { await log($, 'list_sessions failed'); return [] }
69  return JSON.parse(got) as Session[]
70}
71
72const idle = (s: Session, now: number, ms: number) => now - Date.parse(s.lastActivityAt ?? '') > ms
73
74async function archiveIdle($: Engine, now: number, cfg: Cfg, sessions: Session[]) {
75  for (const s of sessions) {
76    if (s.isArchived || s.isRunning || s.pinned || !idle(s, now, cfg.archiveMs)) continue
77    const done = await $.mcp.call(SERVER, 'archive_session', {
78      session_id: s.sessionId,
79      reason: `idle for ${Math.round((now - Date.parse(s.lastActivityAt ?? '')) / MIN)} min (session-reaper)`,
80    })
81    log($, `archive ${s.title ?? s.sessionId}: ${done.isError ? 'refused' : 'ok'}`)
82  }
83}
84
85type Snap = { rows: Row[]; parent: (r: Row) => Row | undefined; tree: (r: Row) => Row[] }
86async function snapshot($: Engine): Promise<Snap | undefined> {
87  const snap = await procs($, [])
88  if (snap.exitCode !== 0) { await log($, `snapshot failed: ${snap.stderr.slice(0, 200)}`); return }
89  const rows = JSON.parse(snap.stdout) as Row[]
90  const byPid = new Map(rows.map(r => [r.pid, r]))
91  // A parent counts only if it is older than the child; a younger one reused the PID.
92  const parent = (r: Row) => {
93    const p = byPid.get(r.ppid)
94    return p && p.pid !== r.pid && createdMs(p.created) <= createdMs(r.created) ? p : undefined
95  }
96  const children = new Map<number, Row[]>()
97  for (const r of rows) {
98    const p = parent(r)
99    if (p) children.set(p.pid, [...(children.get(p.pid) ?? []), r])
100  }
101  const tree = (r: Row): Row[] => [r, ...(children.get(r.pid) ?? []).flatMap(tree)]
102  return { rows, parent, tree }
103}
104
105// Stops the process tree of each idle session. The session stays in the sidebar; the app starts
106// a fresh process when it is opened again.
107async function releaseIdle($: Engine, now: number, cfg: Cfg, sessions: Session[], me: string | undefined) {
108  const owned = ((await $.store.get('owned')) ?? {}) as Owned
109  const snap = await snapshot($)
110  if (!snap) return
111  const live = new Map(snap.rows.map(r => [key(r), r]))
112  // A session whose side session still works is kept: the side session reports back to it.
113  const parents = new Set(sessions.filter(s => s.isRunning && s.startedBy).map(s => s.startedBy))
114  const kill: Row[] = []
115  let changed = false
116  for (const [id, proc] of Object.entries(owned)) {
117    const root = live.get(key(proc))
118    if (!root || root.name.toLowerCase() !== 'claude.exe') { delete owned[id]; changed = true; continue }
119    const s = sessions.find(x => x.sessionId === id)
120    if (!s || id === me || s.isArchived || s.isRunning || parents.has(id) || !idle(s, now, cfg.idleMs)) continue
121    const members = snap.tree(root).filter(r => !SPARED_IN_SESSION.has(r.name.toLowerCase()))
122    kill.push(...members)
123    delete owned[id]
124    changed = true
125    log($, `release ${s.title ?? id}: ${members.length} processes`)
126  }
127  if (changed) await $.store.set('owned', owned)
128  if (!kill.length) return
129  const res = await procs($, ['-Kill', kill.map(key).join(',')])
130  log($, `released ${kill.map(r => `${r.name}(${r.pid})`).join(' ')}: ${res.stdout.slice(0, 300)}`)
131}
132
133async function stopOrphans($: Engine, now: number, cfg: Cfg) {
134  const snap = await snapshot($)
135  if (!snap) return
136  const { rows, parent, tree } = snap
137  const underClaude = (r: Row) => {
138    for (let p = parent(r), hops = 0; p && hops < 64; p = parent(p), hops++) if (p.name.toLowerCase() === 'claude.exe') return true
139    return false
140  }
141
142  const before = ((await $.store.get('tracked')) ?? {}) as Tracked
143  const tracked: Tracked = {}
144  const orphans = new Map<string, Row>()
145  for (const r of rows) {
146    if (r.name.toLowerCase() === 'claude.exe') continue
147    if (underClaude(r)) tracked[key(r)] = { cpu: r.cpu }
148    else if (before[key(r)]) { tracked[key(r)] = { cpu: r.cpu }; orphans.set(key(r), r) }
149  }
150  await $.store.set('tracked', tracked)
151
152  const isIdle = (r: Row) =>
153    SPARED.has(r.name.toLowerCase()) ||
154    (now - createdMs(r.created) >= cfg.orphanMs && r.cpu - before[key(r)]!.cpu <= cfg.cpuIdle)
155
156  const kill: Row[] = []
157  for (const root of orphans.values()) {
158    const p = parent(root)
159    if (p && orphans.has(key(p))) continue
160    const members = tree(root)
161    if (members.every(isIdle)) kill.push(...members.filter(m => !SPARED.has(m.name.toLowerCase())))
162  }
163  if (!kill.length) return
164  const res = await procs($, ['-Kill', kill.map(key).join(',')])
165  log($, `stopped ${kill.map(r => `${r.name}(${r.pid})`).join(' ')}: ${res.stdout.slice(0, 300)}`)
166}
167
168async function tick($: Engine, cfg: Cfg, self: { v?: Self }) {
169  const now = await $.clock.now()
170  try { await reportSelf($, self) } catch (err) { log($, `self report failed: ${String(err).slice(0, 200)}`) }
171  if (!(await isLeader($, now, cfg))) return
172  const sessions = await listSessions($)
173  if (cfg.archiveMs) try { await archiveIdle($, now, cfg, sessions) } catch (err) { log($, `archive failed: ${String(err).slice(0, 200)}`) }
174  try { await releaseIdle($, now, cfg, sessions, self.v?.sessionId) } catch (err) { log($, `release failed: ${String(err).slice(0, 200)}`) }
175  try { await stopOrphans($, now, cfg) } catch (err) { log($, `orphans failed: ${String(err).slice(0, 200)}`) }
176}
177
178export const register: Register = (on, options) => {
179  const num = (v: unknown, d: number) => (typeof v === 'number' && v > 0 ? v : d)
180  const idleMs = num(options.idleMinutes, 60) * MIN
181  const archiveMs = num(options.archiveMinutes, 0) * MIN
182  const orphanMs = num(options.orphanMinutes, 60) * MIN
183  const cpuIdle = num(options.cpuIdleMs, 1000) * 10_000
184  const intervalMs = num(options.intervalMinutes, 5) * MIN
185  const cfg: Cfg = { idleMs, archiveMs, orphanMs, cpuIdle, intervalMs }
186  const self: { v?: Self } = {}
187
188  on('session.start', async ($, e, next) => {
189    const started = await next(e)
190    void reportSelf($, self).catch(err => log($, `self report failed: ${String(err).slice(0, 200)}`))
191    $.clock.every(intervalMs, () => void tick($, cfg, self))
192    return started
193  })
194}
195