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.
- cleanup_orphans-0.1.1/PKG-INFO +92 -0
- cleanup_orphans-0.1.1/README.md +73 -0
- cleanup_orphans-0.1.1/cleanup_orphans/__init__.py +3 -0
- cleanup_orphans-0.1.1/cleanup_orphans/cli.py +72 -0
- cleanup_orphans-0.1.1/cleanup_orphans/fzf.py +56 -0
- cleanup_orphans-0.1.1/cleanup_orphans/killer.py +75 -0
- cleanup_orphans-0.1.1/cleanup_orphans/process.py +149 -0
- cleanup_orphans-0.1.1/cleanup_orphans/scanner.py +233 -0
- cleanup_orphans-0.1.1/cleanup_orphans/version.py +34 -0
- cleanup_orphans-0.1.1/pyproject.toml +72 -0
- cleanup_orphans-0.1.1/pyproject.toml.orig +59 -0
|
@@ -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
|
+
[](https://github.com/iloveitaly/python-cleanup-orphans/releases)
|
|
21
|
+
[](https://pepy.tech/project/cleanup-orphans)
|
|
22
|
+

|
|
23
|
+
[](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
|
+
[](https://github.com/iloveitaly/python-cleanup-orphans/releases)
|
|
2
|
+
[](https://pepy.tech/project/cleanup-orphans)
|
|
3
|
+

|
|
4
|
+
[](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,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
|