cleanup-orphans 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.3
2
+ Name: cleanup-orphans
3
+ Version: 0.1.1
4
+ Summary: Detect and safely terminate orphaned AI agent processes (Cursor, agy, Claude, Codex)
5
+ Keywords: agent,orphan,process,cleanup
6
+ Author: Michael Bianco
7
+ Author-email: Michael Bianco <mike@mikebian.co>
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: click>=8.5.0
14
+ Requires-Dist: pydantic>=2.13.5
15
+ Requires-Dist: structlog-config>=0.15.0
16
+ Requires-Python: >=3.12
17
+ Project-URL: Repository, https://github.com/iloveitaly/python-cleanup-orphans
18
+ Description-Content-Type: text/markdown
19
+
20
+ [![Release Notes](https://img.shields.io/github/release/iloveitaly/python-cleanup-orphans)](https://github.com/iloveitaly/python-cleanup-orphans/releases)
21
+ [![Downloads](https://static.pepy.tech/badge/cleanup-orphans/month)](https://pepy.tech/project/cleanup-orphans)
22
+ ![GitHub CI Status](https://github.com/iloveitaly/python-cleanup-orphans/actions/workflows/build_and_publish.yml/badge.svg)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
24
+
25
+ # Clean Up Orphaned AI Agent Processes
26
+
27
+ I run AI coding tools all day—Cursor, agy, Claude, Codex—and they inevitably leave behind orphaned background workers, zombie MCP servers, and suspended terminal sessions reparented to launchd that chew through RAM and CPU. This CLI scans for abandoned agent processes, lays them out in an interactive fzf interface, and cleanly shuts them down along with their entire descendant process tree.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ uv tool install cleanup-orphans
33
+ ```
34
+
35
+ Or add to an existing project:
36
+
37
+ ```bash
38
+ uv add cleanup-orphans
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ Launch interactive multi-select via `fzf`:
44
+
45
+ ```bash
46
+ cleanup-orphans
47
+ ```
48
+
49
+ Print candidates in a dry-run table:
50
+
51
+ ```bash
52
+ cleanup-orphans --list
53
+ ```
54
+
55
+ Terminate all identified candidates after confirmation:
56
+
57
+ ```bash
58
+ cleanup-orphans --kill-all
59
+ ```
60
+
61
+ ## Features
62
+
63
+ - Scans for reparented Cursor agent workers (PPID 1)
64
+ - Flags suspended or stopped agent sessions (STAT T)
65
+ - Detects runaway agent sessions exceeding CPU thresholds
66
+ - Finds detached headless Claude CLI streaming processes
67
+ - Identifies orphaned OpenAI Codex CLI instances
68
+ - Recursively tracks and terminates child MCP servers, language servers, and watchdogs
69
+ - Graceful termination escalating from SIGTERM to SIGKILL
70
+ - Interactive terminal UI using `fzf` with a fixed column header row
71
+
72
+ ## Comparison
73
+
74
+ Existing utilities fall into two camps: non-interactive batch daemons targeted at specific runtimes (`TheStack-ai/zclean`, `kojott/claude-gc`), or general-purpose process killers (`fkill`, shell wrappers) that lack process-lineage and AI-tool context.
75
+
76
+ `cleanup-orphans` combines AI ecosystem detection with interactive triage:
77
+
78
+ | Feature | `zclean` / `claude-gc` | `fkill` / `fzf` snippets | `cleanup-orphans` |
79
+ |---|---|---|---|
80
+ | Targets AI agent ecosystem | Yes (MCP, Claude) | No (all system procs) | Yes (Cursor workers, agy, Claude, Codex, MCP trees) |
81
+ | Interactive TUI triage | No (batch / cron only) | Yes (`fkill` / `fzf`) | Yes (`fzf` multi-select with `Ctrl-A` and confirmation) |
82
+ | Sorting | Unsorted / Name | Fuzzy search rank | Oldest-first by elapsed wall-clock time |
83
+ | Descendant cascade reaping | Basic | No | Full tree traversal (reaps child MCPs and watchdogs) |
84
+ | Working directory context | No | No | Yes (macOS `libproc` extraction of exact repo/worktree) |
85
+ | Job-control suspensions (`STAT T`) | No | No | Yes (identifies backgrounded `Ctrl-Z` sessions) |
86
+ | Human-readable metrics | Raw memory | Raw PID / Name | Formatted age (`15d 1h ago`) and CPU (`3h 18m`) |
87
+
88
+ ## [MIT License](LICENSE.md)
89
+
90
+ ---
91
+
92
+ *This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
@@ -0,0 +1,73 @@
1
+ [![Release Notes](https://img.shields.io/github/release/iloveitaly/python-cleanup-orphans)](https://github.com/iloveitaly/python-cleanup-orphans/releases)
2
+ [![Downloads](https://static.pepy.tech/badge/cleanup-orphans/month)](https://pepy.tech/project/cleanup-orphans)
3
+ ![GitHub CI Status](https://github.com/iloveitaly/python-cleanup-orphans/actions/workflows/build_and_publish.yml/badge.svg)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ # Clean Up Orphaned AI Agent Processes
7
+
8
+ I run AI coding tools all day—Cursor, agy, Claude, Codex—and they inevitably leave behind orphaned background workers, zombie MCP servers, and suspended terminal sessions reparented to launchd that chew through RAM and CPU. This CLI scans for abandoned agent processes, lays them out in an interactive fzf interface, and cleanly shuts them down along with their entire descendant process tree.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ uv tool install cleanup-orphans
14
+ ```
15
+
16
+ Or add to an existing project:
17
+
18
+ ```bash
19
+ uv add cleanup-orphans
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Launch interactive multi-select via `fzf`:
25
+
26
+ ```bash
27
+ cleanup-orphans
28
+ ```
29
+
30
+ Print candidates in a dry-run table:
31
+
32
+ ```bash
33
+ cleanup-orphans --list
34
+ ```
35
+
36
+ Terminate all identified candidates after confirmation:
37
+
38
+ ```bash
39
+ cleanup-orphans --kill-all
40
+ ```
41
+
42
+ ## Features
43
+
44
+ - Scans for reparented Cursor agent workers (PPID 1)
45
+ - Flags suspended or stopped agent sessions (STAT T)
46
+ - Detects runaway agent sessions exceeding CPU thresholds
47
+ - Finds detached headless Claude CLI streaming processes
48
+ - Identifies orphaned OpenAI Codex CLI instances
49
+ - Recursively tracks and terminates child MCP servers, language servers, and watchdogs
50
+ - Graceful termination escalating from SIGTERM to SIGKILL
51
+ - Interactive terminal UI using `fzf` with a fixed column header row
52
+
53
+ ## Comparison
54
+
55
+ Existing utilities fall into two camps: non-interactive batch daemons targeted at specific runtimes (`TheStack-ai/zclean`, `kojott/claude-gc`), or general-purpose process killers (`fkill`, shell wrappers) that lack process-lineage and AI-tool context.
56
+
57
+ `cleanup-orphans` combines AI ecosystem detection with interactive triage:
58
+
59
+ | Feature | `zclean` / `claude-gc` | `fkill` / `fzf` snippets | `cleanup-orphans` |
60
+ |---|---|---|---|
61
+ | Targets AI agent ecosystem | Yes (MCP, Claude) | No (all system procs) | Yes (Cursor workers, agy, Claude, Codex, MCP trees) |
62
+ | Interactive TUI triage | No (batch / cron only) | Yes (`fkill` / `fzf`) | Yes (`fzf` multi-select with `Ctrl-A` and confirmation) |
63
+ | Sorting | Unsorted / Name | Fuzzy search rank | Oldest-first by elapsed wall-clock time |
64
+ | Descendant cascade reaping | Basic | No | Full tree traversal (reaps child MCPs and watchdogs) |
65
+ | Working directory context | No | No | Yes (macOS `libproc` extraction of exact repo/worktree) |
66
+ | Job-control suspensions (`STAT T`) | No | No | Yes (identifies backgrounded `Ctrl-Z` sessions) |
67
+ | Human-readable metrics | Raw memory | Raw PID / Name | Formatted age (`15d 1h ago`) and CPU (`3h 18m`) |
68
+
69
+ ## [MIT License](LICENSE.md)
70
+
71
+ ---
72
+
73
+ *This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
@@ -0,0 +1,3 @@
1
+ from .version import __version__
2
+
3
+ __all__ = ["__version__"]
@@ -0,0 +1,72 @@
1
+ """CLI entrypoint for cleanup-orphans."""
2
+
3
+ import click
4
+ from structlog_config import configure_logger
5
+
6
+ from .fzf import select_with_fzf
7
+ from .killer import kill_pids
8
+ from .scanner import Candidate, find_candidates
9
+ from .version import __version__
10
+
11
+ log = configure_logger()
12
+
13
+
14
+ def _print_table(candidates: list[Candidate]) -> None:
15
+ header = f"{'PID':<7} {'PPID':<7} {'CATEGORY':<14} {'REASON':<28} {'AGE':<14} {'STARTED':<15} {'CPU':<9} {'DETAIL'}"
16
+ click.echo(header)
17
+ click.echo("-" * 135)
18
+
19
+ for c in candidates:
20
+ click.echo(
21
+ f"{c.pid:<7} {c.ppid:<7} {c.category:<14} {c.reason:<28} {c.age:<14} {c.started:<15} {c.cpu:<9} {c.detail}"
22
+ )
23
+
24
+ click.echo("-" * 135)
25
+ click.echo(f"Total candidates: {len(candidates)}")
26
+
27
+
28
+ @click.command()
29
+ @click.version_option(version=__version__)
30
+ @click.option(
31
+ "--list",
32
+ "list_only",
33
+ is_flag=True,
34
+ help="List candidates in table format (dry-run)",
35
+ )
36
+ @click.option(
37
+ "--kill-all", is_flag=True, help="Kill all candidates with confirmation prompt"
38
+ )
39
+ def cli(list_only: bool, kill_all: bool) -> None:
40
+ """Detect and safely terminate orphaned AI agent processes.
41
+
42
+ Default mode opens an interactive fzf selector.
43
+ """
44
+ candidates = find_candidates()
45
+ if not candidates:
46
+ click.echo("No orphaned or rogue AI processes found.")
47
+ return
48
+
49
+ if list_only:
50
+ _print_table(candidates)
51
+ return
52
+
53
+ if kill_all:
54
+ _print_table(candidates)
55
+ if click.confirm(f"\nKill all {len(candidates)} candidates?", default=False):
56
+ kill_pids([c.pid for c in candidates])
57
+ else:
58
+ click.echo("Aborted.")
59
+ return
60
+
61
+ # default: interactive fzf selection
62
+ selected_pids = select_with_fzf(candidates)
63
+ if not selected_pids:
64
+ click.echo("Aborted. No processes selected.")
65
+ return
66
+
67
+ if click.confirm(
68
+ f"\nKill {len(selected_pids)} selected process(es)?", default=False
69
+ ):
70
+ kill_pids(selected_pids)
71
+ else:
72
+ click.echo("Aborted.")
@@ -0,0 +1,56 @@
1
+ """Interactive fzf-based process selection."""
2
+
3
+ import subprocess
4
+ import sys
5
+
6
+ from .scanner import Candidate
7
+
8
+
9
+ def select_with_fzf(candidates: list[Candidate]) -> list[int]:
10
+ """Present candidates in fzf multi-select and return selected PIDs."""
11
+ # column header shown as a fixed non-selectable line in fzf
12
+ header = (
13
+ f"{'PID':<7} | {'TYPE':<14} | {'REASON':<28} | {'AGE':<12} "
14
+ f"| {'STARTED':<15} | {'CPU':<8} | DETAIL"
15
+ )
16
+
17
+ lines = [header]
18
+ for c in candidates:
19
+ line = (
20
+ f"{c.pid:<7} | {c.category:<14} | {c.reason:<28} | {c.age:<12} "
21
+ f"| {c.started:<15} | {c.cpu:<8} | {c.detail}"
22
+ )
23
+ lines.append(line)
24
+
25
+ fzf_input = "\n".join(lines)
26
+ try:
27
+ proc = subprocess.Popen(
28
+ [
29
+ "fzf",
30
+ "-m",
31
+ "--layout=reverse",
32
+ "--no-sort",
33
+ # first line of input is the column header
34
+ "--header-lines=1",
35
+ "--header=TAB: select/deselect | Ctrl-A: toggle all | ENTER: kill selected | ESC: quit",
36
+ "--bind=ctrl-a:toggle-all",
37
+ ],
38
+ stdin=subprocess.PIPE,
39
+ stdout=subprocess.PIPE,
40
+ text=True,
41
+ )
42
+ stdout, _ = proc.communicate(input=fzf_input)
43
+
44
+ if proc.returncode != 0:
45
+ return []
46
+
47
+ selected_pids = []
48
+ for line in stdout.strip().splitlines():
49
+ pid_str = line.split("|")[0].strip()
50
+ if pid_str.isdigit():
51
+ selected_pids.append(int(pid_str))
52
+
53
+ return selected_pids
54
+ except FileNotFoundError:
55
+ print("Error: fzf is not installed or not in PATH.", file=sys.stderr)
56
+ sys.exit(1)
@@ -0,0 +1,75 @@
1
+ """Process termination with SIGTERM -> SIGKILL escalation."""
2
+
3
+ import os
4
+ import signal
5
+ import subprocess
6
+ import time
7
+
8
+ import structlog
9
+
10
+ log = structlog.get_logger()
11
+
12
+
13
+ def _expand_descendants(pids: list[int]) -> list[int]:
14
+ """Find all recursive child processes so no orphans are left behind."""
15
+ all_pids = set(pids)
16
+ out = subprocess.check_output(["ps", "-axo", "pid,ppid"]).decode("utf-8")
17
+
18
+ pairs: list[tuple[int, int]] = []
19
+ for line in out.strip().splitlines()[1:]:
20
+ parts = line.strip().split()
21
+ if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
22
+ pairs.append((int(parts[0]), int(parts[1])))
23
+
24
+ added = True
25
+ while added:
26
+ added = False
27
+ for pid, ppid in pairs:
28
+ if ppid in all_pids and pid not in all_pids:
29
+ all_pids.add(pid)
30
+ added = True
31
+
32
+ return sorted(all_pids)
33
+
34
+
35
+ def kill_pids(pids: list[int]) -> None:
36
+ """Send SIGTERM to pids (plus descendants), escalating to SIGKILL after 1s."""
37
+ if not pids:
38
+ return
39
+
40
+ targets = _expand_descendants(pids)
41
+ if len(targets) > len(pids):
42
+ extra = len(targets) - len(pids)
43
+ log.info(
44
+ "expanding targets", selected=len(pids), children=extra, total=len(targets)
45
+ )
46
+
47
+ log.info("sending SIGTERM", pids=targets)
48
+ for pid in targets:
49
+ try:
50
+ os.kill(pid, signal.SIGTERM)
51
+ except ProcessLookupError:
52
+ pass
53
+ except PermissionError:
54
+ log.warning("permission denied", pid=pid)
55
+
56
+ time.sleep(1.0)
57
+
58
+ # check for survivors and escalate
59
+ still_alive = []
60
+ for pid in targets:
61
+ try:
62
+ os.kill(pid, 0)
63
+ still_alive.append(pid)
64
+ except OSError:
65
+ pass
66
+
67
+ if still_alive:
68
+ log.info("escalating to SIGKILL", pids=still_alive)
69
+ for pid in still_alive:
70
+ try:
71
+ os.kill(pid, signal.SIGKILL)
72
+ except OSError:
73
+ pass
74
+
75
+ log.info("kill complete", pids=targets)
@@ -0,0 +1,149 @@
1
+ """Process info helpers: cwd lookup, time/cpu parsing, and formatting."""
2
+
3
+ import contextlib
4
+ import ctypes
5
+ import ctypes.util
6
+ import os
7
+ import subprocess
8
+
9
+
10
+ def get_current_process_tree() -> set[int]:
11
+ """Return PIDs in our own process lineage so we never kill ourselves."""
12
+ pids = set()
13
+ pid = os.getpid()
14
+
15
+ while pid > 1:
16
+ pids.add(pid)
17
+ try:
18
+ out = (
19
+ subprocess.check_output(["ps", "-o", "ppid=", "-p", str(pid)])
20
+ .decode()
21
+ .strip()
22
+ )
23
+ pid = int(out)
24
+ except (subprocess.SubprocessError, ValueError):
25
+ break
26
+
27
+ return pids
28
+
29
+
30
+ def parse_cpu_minutes(cputime: str) -> float:
31
+ """Parse BSD ps cputime (minutes:seconds.hundredths) into float minutes."""
32
+ parts = cputime.split(":")
33
+
34
+ if len(parts) == 2:
35
+ return float(parts[0]) + float(parts[1]) / 60.0
36
+ elif len(parts) == 3:
37
+ # hours:minutes:seconds
38
+ return float(parts[0]) * 60.0 + float(parts[1]) + float(parts[2]) / 60.0
39
+
40
+ return 0.0
41
+
42
+
43
+ def parse_elapsed_seconds(etime: str) -> int:
44
+ """Convert BSD etime ([[dd-]hh:]mm:ss) into integer seconds."""
45
+ days, hours, mins, secs = 0, 0, 0, 0
46
+
47
+ if "-" in etime:
48
+ day_part, rest = etime.split("-", 1)
49
+ days = int(day_part)
50
+ time_parts = rest.split(":")
51
+ else:
52
+ time_parts = etime.split(":")
53
+
54
+ if len(time_parts) == 3:
55
+ hours, mins, secs = map(int, time_parts)
56
+ elif len(time_parts) == 2:
57
+ mins, secs = map(int, time_parts)
58
+ elif len(time_parts) == 1:
59
+ secs = int(time_parts[0])
60
+
61
+ return days * 86_400 + hours * 3_600 + mins * 60 + secs
62
+
63
+
64
+ def format_elapsed(etime: str) -> str:
65
+ """Convert BSD etime ([[dd-]hh:]mm:ss) into a human-readable age string."""
66
+ days, hours, mins, secs = 0, 0, 0, 0
67
+
68
+ if "-" in etime:
69
+ day_part, rest = etime.split("-", 1)
70
+ days = int(day_part)
71
+ time_parts = rest.split(":")
72
+ else:
73
+ time_parts = etime.split(":")
74
+
75
+ if len(time_parts) == 3:
76
+ hours, mins, secs = map(int, time_parts)
77
+ elif len(time_parts) == 2:
78
+ mins, secs = map(int, time_parts)
79
+ elif len(time_parts) == 1:
80
+ secs = int(time_parts[0])
81
+
82
+ if days > 0:
83
+ return f"{days}d {hours}h ago"
84
+ if hours > 0:
85
+ return f"{hours}h {mins}m ago"
86
+ if mins > 0:
87
+ return f"{mins}m ago"
88
+ return f"{secs}s ago"
89
+
90
+
91
+ def format_cpu(cputime: str) -> str:
92
+ """Convert BSD ps cputime into human-readable form."""
93
+ parts = cputime.split(":")
94
+
95
+ if len(parts) == 2:
96
+ m = int(parts[0])
97
+ s = float(parts[1])
98
+ if m >= 60:
99
+ return f"{m // 60}h {m % 60}m"
100
+ if m > 0:
101
+ return f"{m}m {int(s)}s"
102
+ return f"{s:.1f}s"
103
+ elif len(parts) == 3:
104
+ h = int(parts[0])
105
+ m = int(parts[1])
106
+ return f"{h}h {m}m"
107
+
108
+ return f"{float(parts[0]):.1f}s"
109
+
110
+
111
+ # C-level libproc wrapper for fast process cwd retrieval on macOS
112
+ _libproc = None
113
+ with contextlib.suppress(OSError):
114
+ _libproc_path = ctypes.util.find_library("proc")
115
+ if _libproc_path:
116
+ _libproc = ctypes.CDLL(_libproc_path)
117
+ _libproc.proc_pidinfo.argtypes = [
118
+ ctypes.c_int,
119
+ ctypes.c_int,
120
+ ctypes.c_uint64,
121
+ ctypes.c_void_p,
122
+ ctypes.c_int,
123
+ ]
124
+ _libproc.proc_pidinfo.restype = ctypes.c_int
125
+
126
+
127
+ def get_process_cwd(pid: int) -> str:
128
+ """Query current working directory using macOS libproc (PROC_PIDVNODEPATHINFO = 9)."""
129
+ if not _libproc:
130
+ return ""
131
+
132
+ with contextlib.suppress(OSError, ValueError):
133
+ buf = ctypes.create_string_buffer(2_352)
134
+ # flavor 9 = PROC_PIDVNODEPATHINFO
135
+ ret = _libproc.proc_pidinfo(pid, 9, 0, buf, ctypes.sizeof(buf))
136
+ if ret <= 0:
137
+ return ""
138
+
139
+ # offset 152 is char vip_path[1024] inside pvi_cdir
140
+ path_bytes = buf.raw[152 : 152 + 1_024]
141
+ null_idx = path_bytes.find(b"\x00")
142
+ if null_idx != -1:
143
+ path_bytes = path_bytes[:null_idx]
144
+
145
+ path_str = path_bytes.decode("utf-8", errors="replace").strip()
146
+ if path_str:
147
+ return path_str.replace(os.path.expanduser("~"), "~")
148
+
149
+ return ""
@@ -0,0 +1,233 @@
1
+ """Scan running processes to find orphaned AI agent candidates."""
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from .process import (
9
+ format_cpu,
10
+ format_elapsed,
11
+ get_current_process_tree,
12
+ get_process_cwd,
13
+ parse_cpu_minutes,
14
+ parse_elapsed_seconds,
15
+ )
16
+
17
+
18
+ class Candidate(BaseModel):
19
+ pid: int
20
+ ppid: int
21
+ category: str
22
+ reason: str
23
+ cpu: str
24
+ age: str
25
+ elapsed_seconds: int
26
+ started: str
27
+ cwd: str
28
+ detail: str
29
+
30
+
31
+ class RawProcess(BaseModel):
32
+ pid: int
33
+ ppid: int
34
+ stat: str
35
+ etime: str
36
+ cputime: str
37
+ started: str
38
+ cmd: str
39
+
40
+
41
+ def _parse_process_list() -> list[RawProcess]:
42
+ """Parse `ps` output into structured process records."""
43
+ out = subprocess.check_output(
44
+ ["ps", "-axo", "pid,ppid,stat,etime,cputime,lstart,command"]
45
+ ).decode("utf-8")
46
+
47
+ processes: list[RawProcess] = []
48
+ for line in out.splitlines()[1:]:
49
+ parts = line.strip().split(None, 10)
50
+ if len(parts) < 11:
51
+ continue
52
+
53
+ pid_s, ppid_s, stat, etime, cputime, _day, month, date, time_str, _year, cmd = (
54
+ parts
55
+ )
56
+ processes.append(
57
+ RawProcess(
58
+ pid=int(pid_s),
59
+ ppid=int(ppid_s),
60
+ stat=stat,
61
+ etime=etime,
62
+ cputime=cputime,
63
+ started=f"{month} {date} {time_str[:5]}",
64
+ cmd=cmd,
65
+ )
66
+ )
67
+
68
+ return processes
69
+
70
+
71
+ def _build_candidate(
72
+ proc: RawProcess, category: str, reason: str, detail: str, cwd: str
73
+ ) -> Candidate:
74
+ return Candidate(
75
+ pid=proc.pid,
76
+ ppid=proc.ppid,
77
+ category=category,
78
+ reason=reason,
79
+ cpu=format_cpu(proc.cputime),
80
+ age=format_elapsed(proc.etime),
81
+ elapsed_seconds=parse_elapsed_seconds(proc.etime),
82
+ started=proc.started,
83
+ cwd=cwd,
84
+ detail=detail,
85
+ )
86
+
87
+
88
+ def _check_cursor_worker(proc: RawProcess) -> Candidate | None:
89
+ """Cursor agent workers reparented to launchd (PPID 1) are orphans."""
90
+ if (
91
+ "cursor-agent" not in proc.cmd
92
+ or "worker start" not in proc.cmd
93
+ or proc.ppid != 1
94
+ ):
95
+ return None
96
+
97
+ ws = ""
98
+ if "--worker-dir" in proc.cmd:
99
+ ws = proc.cmd.split("--worker-dir")[1].strip().split()[0]
100
+ ws = ws.replace(os.path.expanduser("~"), "~")
101
+
102
+ cwd = get_process_cwd(proc.pid)
103
+ return _build_candidate(
104
+ proc, "cursor-agent", "Reparented to launchd (PPID 1)", ws or cwd, cwd or ws
105
+ )
106
+
107
+
108
+ def _check_agy(proc: RawProcess) -> Candidate | None:
109
+ """AGY sessions that are orphaned, suspended, or consuming excessive CPU."""
110
+ is_agy = "agy " in proc.cmd or proc.cmd.endswith("agy")
111
+ if not is_agy:
112
+ return None
113
+
114
+ cwd = get_process_cwd(proc.pid)
115
+ detail = f"[{cwd}] {proc.cmd[:50]}" if cwd else proc.cmd[:60]
116
+
117
+ if proc.ppid == 1:
118
+ return _build_candidate(
119
+ proc, "agy", "Reparented to launchd (PPID 1)", detail, cwd
120
+ )
121
+ if "T" in proc.stat:
122
+ return _build_candidate(
123
+ proc, "agy", f"Suspended (STAT {proc.stat})", detail, cwd
124
+ )
125
+ # 8 hours of CPU is excessive for any single agent session
126
+ if parse_cpu_minutes(proc.cputime) > 480:
127
+ return _build_candidate(proc, "agy", "High CPU usage", detail, cwd)
128
+
129
+ return None
130
+
131
+
132
+ def _is_codex_process(cmd: str) -> bool:
133
+ return "codex " in cmd or cmd.endswith("codex") or "codex-code-mode-host" in cmd
134
+
135
+
136
+ def _check_codex(proc: RawProcess) -> Candidate | None:
137
+ """Codex CLI processes that are orphaned, suspended, or consuming excessive CPU."""
138
+ if not _is_codex_process(proc.cmd):
139
+ return None
140
+
141
+ cwd = get_process_cwd(proc.pid)
142
+ detail = f"[{cwd}] {proc.cmd[:50]}" if cwd else proc.cmd[:60]
143
+
144
+ if proc.ppid == 1:
145
+ return _build_candidate(
146
+ proc, "codex", "Reparented to launchd (PPID 1)", detail, cwd
147
+ )
148
+ if "T" in proc.stat:
149
+ return _build_candidate(
150
+ proc, "codex", f"Suspended (STAT {proc.stat})", detail, cwd
151
+ )
152
+ if parse_cpu_minutes(proc.cputime) > 480:
153
+ return _build_candidate(proc, "codex", "High CPU usage", detail, cwd)
154
+
155
+ return None
156
+
157
+
158
+ def _check_claude_stream(proc: RawProcess) -> Candidate | None:
159
+ """Detached headless Claude stream-json processes."""
160
+ if "claude " not in proc.cmd or "--output-format stream-json" not in proc.cmd:
161
+ return None
162
+
163
+ cwd = get_process_cwd(proc.pid)
164
+ detail = f"[{cwd}] {proc.cmd[:50]}" if (cwd and cwd != "/") else proc.cmd[:60]
165
+ return _build_candidate(proc, "claude", "Detached headless stream", detail, cwd)
166
+
167
+
168
+ # ordered list of detection heuristics
169
+ _CHECKERS = [
170
+ _check_cursor_worker,
171
+ _check_codex,
172
+ _check_agy,
173
+ _check_claude_stream,
174
+ ]
175
+
176
+
177
+ def _collect_descendants(
178
+ candidate_pids: set[int],
179
+ candidates_by_pid: dict[int, Candidate],
180
+ all_processes: list[RawProcess],
181
+ ) -> list[Candidate]:
182
+ """Recursively find child processes of candidates (e.g. MCP servers spawned by cursor workers)."""
183
+ descendants: list[Candidate] = []
184
+ added = True
185
+
186
+ while added:
187
+ added = False
188
+ for proc in all_processes:
189
+ if proc.ppid not in candidate_pids or proc.pid in candidate_pids:
190
+ continue
191
+
192
+ parent = candidates_by_pid.get(proc.ppid)
193
+ parent_label = parent.category if parent else "unknown"
194
+ reason = f"Child of {parent_label} ({proc.ppid})"
195
+
196
+ cwd = get_process_cwd(proc.pid)
197
+ detail = f"[{cwd}] {proc.cmd[:45]}" if cwd else proc.cmd[:60]
198
+ child = _build_candidate(proc, "child", reason, detail, cwd)
199
+ descendants.append(child)
200
+ # register so grandchildren can resolve their parent too
201
+ candidates_by_pid[proc.pid] = child
202
+ candidate_pids.add(proc.pid)
203
+ added = True
204
+
205
+ return descendants
206
+
207
+
208
+ def find_candidates() -> list[Candidate]:
209
+ """Scan all running processes and return orphaned AI agent candidates, oldest first."""
210
+ current_tree = get_current_process_tree()
211
+ all_processes = _parse_process_list()
212
+
213
+ candidates: list[Candidate] = []
214
+ for proc in all_processes:
215
+ if proc.pid in current_tree:
216
+ continue
217
+
218
+ for checker in _CHECKERS:
219
+ candidate = checker(proc)
220
+ if candidate:
221
+ candidates.append(candidate)
222
+ # only match the first applicable category
223
+ break
224
+
225
+ # collect child processes of all matched candidates
226
+ candidate_pids = {c.pid for c in candidates}
227
+ candidates_by_pid = {c.pid: c for c in candidates}
228
+ descendants = _collect_descendants(candidate_pids, candidates_by_pid, all_processes)
229
+ candidates.extend(descendants)
230
+
231
+ # oldest first
232
+ candidates.sort(key=lambda c: c.elapsed_seconds, reverse=True)
233
+ return candidates
@@ -0,0 +1,34 @@
1
+ """Version handling for cleanup-orphans."""
2
+
3
+ import importlib.metadata
4
+ from pathlib import Path
5
+
6
+
7
+ def is_local_source_checkout() -> bool:
8
+ """Check if the code is running from a local source checkout."""
9
+ package_dir = Path(__file__).resolve().parent
10
+ # Since this is a flat layout (module-root = ""), repo root is the parent of the package dir
11
+ repo_root = package_dir.parent
12
+
13
+ return (repo_root / ".git").exists() and (repo_root / "pyproject.toml").exists()
14
+
15
+
16
+ def get_version() -> str:
17
+ """Get the version string, appending .dev if running from source."""
18
+ try:
19
+ # Try to get the version of the installed package
20
+ version = importlib.metadata.version("cleanup-orphans")
21
+ except importlib.metadata.PackageNotFoundError:
22
+ # Fallback for local development if not installed
23
+ version = "0.1.0"
24
+
25
+ if not is_local_source_checkout():
26
+ return version
27
+
28
+ if version.endswith(".dev"):
29
+ return version
30
+
31
+ return f"{version}.dev"
32
+
33
+
34
+ __version__ = get_version()
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "cleanup-orphans"
3
+ version = "0.1.1"
4
+ description = "Detect and safely terminate orphaned AI agent processes (Cursor, agy, Claude, Codex)"
5
+ keywords = [
6
+ "agent",
7
+ "orphan",
8
+ "process",
9
+ "cleanup",
10
+ ]
11
+ readme = "README.md"
12
+ requires-python = ">=3.12"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ ]
20
+ dependencies = [
21
+ "click>=8.5.0",
22
+ "pydantic>=2.13.5",
23
+ "structlog-config>=0.15.0",
24
+ ]
25
+
26
+ [[project.authors]]
27
+ name = "Michael Bianco"
28
+ email = "mike@mikebian.co"
29
+
30
+ [project.urls]
31
+ Repository = "https://github.com/iloveitaly/python-cleanup-orphans"
32
+
33
+ [project.scripts]
34
+ cleanup-orphans = "cleanup_orphans.cli:cli"
35
+ python-cleanup-orphans = "cleanup_orphans.cli:cli"
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.11.0,<0.14"]
39
+ build-backend = "uv_build"
40
+
41
+ [tool.uv.build-backend]
42
+ module-root = ""
43
+
44
+ [tool.pyright]
45
+ exclude = [
46
+ "examples/",
47
+ "playground/",
48
+ "tmp/",
49
+ ".venv/",
50
+ "tests/",
51
+ ]
52
+
53
+ [tool.pytest.ini_options]
54
+ addopts = "--cov --cov-report=term-missing --cov-report=html:tmp/htmlcov"
55
+
56
+ [tool.coverage.run]
57
+ plugins = ["covdefaults"]
58
+ source = ["cleanup_orphans"]
59
+
60
+ [tool.coverage.report]
61
+ fail_under = 10
62
+
63
+ [dependency-groups]
64
+ dev = [
65
+ "pytest>=8.3.4",
66
+ "pyright[nodejs]>=1.1.408",
67
+ "ruff>=0.15.0",
68
+ "coverage>=7.13.4",
69
+ "pytest-cov>=7.0.0",
70
+ "covdefaults>=2.3.0",
71
+ "beautiful-traceback>=1.0.2",
72
+ ]
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "cleanup-orphans"
3
+ version = "0.1.1"
4
+ description = "Detect and safely terminate orphaned AI agent processes (Cursor, agy, Claude, Codex)"
5
+ keywords = ["agent", "orphan", "process", "cleanup"]
6
+ readme = "README.md"
7
+ requires-python = ">=3.12"
8
+ classifiers = [
9
+ "Programming Language :: Python :: 3",
10
+ "Programming Language :: Python :: 3 :: Only",
11
+ "Programming Language :: Python :: 3.12",
12
+ "Programming Language :: Python :: 3.13",
13
+ "Programming Language :: Python :: 3.14",
14
+ ]
15
+ dependencies = [
16
+ "click>=8.5.0",
17
+ "pydantic>=2.13.5",
18
+ "structlog-config>=0.15.0",
19
+ ]
20
+ authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
21
+ urls = { "Repository" = "https://github.com/iloveitaly/python-cleanup-orphans" }
22
+
23
+
24
+ [project.scripts]
25
+ cleanup-orphans = "cleanup_orphans.cli:cli"
26
+ python-cleanup-orphans = "cleanup_orphans.cli:cli"
27
+
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.11.0,<0.14"]
31
+ build-backend = "uv_build"
32
+
33
+ [tool.uv.build-backend]
34
+ # avoids the src/ directory structure
35
+ module-root = ""
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=8.3.4",
40
+ "pyright[nodejs]>=1.1.408",
41
+ "ruff>=0.15.0",
42
+ "coverage>=7.13.4",
43
+ "pytest-cov>=7.0.0",
44
+ "covdefaults>=2.3.0",
45
+ "beautiful-traceback>=1.0.2",
46
+ ]
47
+
48
+ [tool.pyright]
49
+ exclude = ["examples/", "playground/", "tmp/", ".venv/", "tests/"]
50
+
51
+ [tool.pytest.ini_options]
52
+ addopts = "--cov --cov-report=term-missing --cov-report=html:tmp/htmlcov"
53
+
54
+ [tool.coverage.run]
55
+ plugins = ["covdefaults"]
56
+ source = ["cleanup_orphans"]
57
+
58
+ [tool.coverage.report]
59
+ fail_under = 10