Metadata-Version: 2.4
Name: mem-report
Version: 0.1.1
Summary: Find and clear CLI agent processes that outlived their session and are burning RAM
Project-URL: Repository, https://github.com/frndvrgs/mem-report
Author-email: frndvrgs <frndvrgs@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agent,cli,memory,oom,orphan,process,procfs
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Monitoring
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: mypy>=1.20.2; extra == 'dev'
Requires-Dist: pytest>=9.0.3; extra == 'dev'
Requires-Dist: ruff>=0.15.12; extra == 'dev'
Description-Content-Type: text/markdown

# mem-report

Find and clear CLI agent processes that outlived their session and are quietly burning RAM.

Coding agents (Claude Code, Codex, Gemini CLI, aider, …) shell out constantly — to `ugrep`, `rg`, `find`, `git`, `node`. When a session dies, those children are reparented to `init` and keep running. A search with a catastrophically backtracking regex can sit at **9 GiB** for sixteen hours with nothing pointing at it, because the process that started it is long gone.

`mem-report` looks at the whole process tree instead of the session process, so a runaway `grep` is attributed to the agent that spawned it — and can be cleared safely.

## Why per-process memory reporting misses this

A watcher that samples "every Claude process" reports a healthy 300 MB session while its orphaned grandchild holds 9 GiB. The memory is real, the session looks fine, and nothing correlates the two.

```
 PID      RSS_MB   AGE        <- what per-process sampling shows
 3894245  241      00:04:12       one healthy session

 PID      RSS_MB   AGE        <- what is actually resident
 2496994  9252     15:49:55      orphaned ugrep, parent died hours ago
 3869453  8435     00:23:47      orphaned ugrep under an orphaned agent shell
```

## Installation

```bash
uv tool install mem-report   # or: pip install mem-report
```

Linux only — it reads `/proc` directly and has no runtime dependencies. Both `memr` and `mem-report` are installed as commands. Run `memr -v` to check the installed version.

## Usage

```bash
memr                      # the report (default command)
memr --all                # plus the top memory consumers system-wide
memr --explain            # why each stray was or was not selected
memr --json               # machine-readable

memr reap                 # show the plan, then ask before signalling
memr reap --dry-run       # never signals, whatever happens
memr reap --yes           # no prompt
memr reap --pid 12345     # clear a specific tree

memr watch                # sample process trees into a history log
memr watch --once         # single sample, for cron
```

### Output

```
mem-report  ASUS-A16  2026-08-05 03:13:36

  RAM     19.5 GiB total    19.1 GiB used     0.5 GiB available   97% used
  Swap     8.0 GiB total     6.8 GiB used
  OOM kills since boot: 0

  SESSIONS (1)
  PID      AGENT   RSS   SWAP  SUBTREE  AGE       FLAGS  COMMAND
  3894245  claude  241   76    317      00:04:12  -      claude

  STRAY PROCESSES (3)
  PID      TOOL    RSS   SWAP  AGE       FLAGS                   REAP  COMMAND
  2496994  ugrep   9252  2046  15:49:55  runaway orphan blocked  yes   ugrep -G -oin .{0,60}leech…
  3869452  claude-shell 1  0   00:23:47  runaway orphan          yes   /bin/bash -c source /home…
  3869453  ugrep   8435  1890  00:23:47  runaway blocked         -     ugrep -noE [^.]{0,45}(rat…

  HISTORY (5,658 samples since 2026-08-04T15:57:52-03:00)
  peak footprint  11,298 MB  pid 2496994 ugrep at 02:59:32
  over 2,048 MB      2 process(es)  <-- investigate

  2 process(es) reapable, holding 19.0 GiB

  KILL THEM
    memr reap --yes                  reap all 2 flagged above
    memr reap --pid 2496994 --yes    reap only ugrep (11,298 MB)
    kill -9 2496994 3869452          skip memr, signal directly
  pid 2496994 in uninterruptible sleep (D): SIGTERM cannot land, memr escalates to SIGKILL on its own
```

`SUBTREE` is the headline number: the session's own RSS plus every descendant's RSS and swap. A session at 241 MB with a 9 GiB subtree is the case this tool exists to surface.

Every report ends with the command that clears what it just found, pid included — copy the line, paste it, move on. Nothing suggested there can silently fail: processes owned by another uid are flagged `foreign` and excluded from the commands, called out separately instead.

When nothing is auto-reapable but something heavy was deliberately spared, the footer offers the override instead:

```
  nothing auto-reapable, but 1 heavy process(es) were spared

  KILL THEM ANYWAY (name the pid to override the guard)
    memr reap --pid 3869453 --yes  ugrep, 10,325 MB — runaway under live session 3894245
```

## What counts as a stray

A process is **reapable** only when every one of these holds:

1. it is orphaned (`PPID 1`) — its spawning session is gone
2. it has no controlling terminal
3. it is older than `--min-age` (default 60s, so processes still starting up are never touched)
4. it is owned by you (or you are root)
5. it is neither the running `memr` process nor any ancestor of it
6. it is one of:
   - an **agent session** (`claude`, `codex`, `gemini`, `aider`, `copilot`, `cursor-agent`, `opencode`, `goose`, `amp`, `qwen`, `crush`)
   - a **transient tool** — search (`ugrep`, `rg`, `grep`, `find`, `fd`, …), text (`sed`, `awk`, `jq`, …), or VCS (`git`, `hg`, …)
   - an **agent-spawned shell**, identified by its shell-snapshot path
   - an **orphaned shell currently running a transient tool** — the wrapper case

Everything else is reported but never selected.

### What is deliberately spared

| Case | Why |
| ---- | --- |
| Orphaned `node`, `python`, `tsc`, `cargo`, … | These legitimately run detached. Opt in with `--include-persistent`. |
| An orphaned shell with no transient child | A detached `bash` daemon (a watcher, a supervisor) looks exactly like an orphaned agent shell. Requiring a live search tool underneath separates them. |
| A runaway under a **live** session | The session may still be using it. Flagged `runaway`; the footer prints the `--pid` command to override. |
| Anything owned by another user | Flagged `foreign`, reported, never signalled — and never suggested as a command, since it could not land. |
| Processes on a terminal | Someone is attached. |

Children of a reapable root are killed with it — `plan` expands each root to its full subtree and signals **deepest-first**, so a wrapper shell cannot outlive its worker or vice versa.

## Signal escalation

`SIGTERM`, then `SIGKILL` after `--grace` seconds (default 5).

This matters more than it sounds. A process thrashing inside the memory cgroup's reclaim path sits in uninterruptible sleep (`D` state) and **will not act on `SIGTERM`** — the signal is queued but never handled. Targets in `D` state are marked in the plan, and escalation is what actually clears them.

```bash
memr reap --signal KILL     # skip straight to SIGKILL
memr reap --no-escalate     # SIGTERM only, never escalate
memr reap --grace 30        # give slow shutdowns longer
```

## Watch mode

`memr watch` samples every 30s into a JSONL log (`$XDG_STATE_HOME/mem-report/history.jsonl`, default `~/.local/state/mem-report/history.jsonl`), rotating at 10 MB. `memr` reads it back for the `HISTORY` section, so peaks that happened while you were away are still visible.

As a systemd user service:

```ini
[Unit]
Description=Sample CLI agent process trees for memory growth

[Service]
Type=simple
ExecStart=%h/.local/bin/memr watch
Restart=always
RestartSec=10
Nice=10
MemoryMax=64M

[Install]
WantedBy=default.target
```

```bash
systemctl --user enable --now mem-report-watch.service
```

## Exit codes

| Code | Meaning |
| ---- | ------- |
| 0 | success |
| 1 | `report --exit-code` found reapable processes; or `reap` left something alive |
| 2 | not a Linux host with `/proc` |
| 130 | interrupted |

`report` exits 0 by default even when it finds strays. Pass `--exit-code` to make it fail for monitoring.

```bash
memr --exit-code --no-history || notify-send "stray agent processes"
```

## Options

| Flag | Applies to | Default | Meaning |
| ---- | ---------- | ------- | ------- |
| `--threshold-mb MB` | all | 2048 | subtree footprint that counts as runaway |
| `--min-age SECONDS` | all | 60 | ignore orphans younger than this |
| `--stale-age SECONDS` | all | 14400 | age at which an orphan is flagged stale |
| `--include-persistent` | all | off | also reap orphaned runtimes and build tools |
| `--json` | report | off | machine-readable output |
| `--all` | report | off | add system-wide top memory consumers |
| `--top N` | report | 10 | rows to show for `--all` |
| `--explain` | report | off | print the decision for every stray |
| `--exit-code` | report | off | exit 1 when strays are reapable |
| `--no-history` | report | off | skip the watch-log summary |
| `-y`, `--yes` | reap | off | skip the confirmation prompt |
| `-n`, `--dry-run` | reap | off | print the plan, never signal |
| `--pid PID [PID …]` | reap | — | reap these trees regardless of heuristics |
| `--signal NAME` | reap | TERM | first signal (TERM, KILL, INT, HUP) |
| `--grace SECONDS` | reap | 5 | wait before escalating to SIGKILL |
| `--no-escalate` | reap | off | never follow up with SIGKILL |
| `--interval SECONDS` | watch | 30 | seconds between samples |
| `--once` | watch | off | single sample, then exit |
| `--max-bytes BYTES` | watch | 10485760 | rotate the log above this size |
| `--verbose` | watch | off | report each sample on stderr |
| `--log PATH` | report, watch | see above | history log location |

Colour is disabled automatically when stdout is not a terminal, and honours `NO_COLOR`.

## Development

```bash
uv venv && uv pip install -e '.[dev]'
uv run ruff check . && uv run ruff format --check .
uv run mypy src tests
uv run pytest
```

## Prior art

`memr` grew out of two bash scripts, `claude-mem-report` and `claude-memwatch`, which sampled per-process RSS for Claude Code only. The first is where the process-tree attribution fix started, and that fix is what motivated this rewrite. Both are superseded and no longer shipped — they remain in the git history.

## License

MIT
