File size: 4,839 Bytes
981aa5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import os
import signal
import subprocess
import time


PROCESS_KEYWORDS = (
    "claudecodepanel",
    "claude-code",
    "claude code",
    "claude",
    "codex",
    "opencode",
    "mcp-server",
    "server-filesystem",
    "server-memory",
    "server-github",
    "gemini-mcp",
    "gemini",
)

STALE_SECONDS = 24 * 3600
CLK_TCK = os.sysconf(os.sysconf_names.get("SC_CLK_TCK", "SC_CLK_TCK"))


@dataclass(frozen=True)
class ProcessInfo:
    pid: int
    name: str
    command: str
    cpu: float
    ram_mb: float
    tty: str
    cwd: str
    uptime_seconds: float
    stale: bool
    family: str = "Agent"


def _match_keyword(command: str) -> str | None:
    lower = command.lower()
    if "codex_dashboard" in lower or "codex-dashboard" in lower:
        return None
    for keyword in PROCESS_KEYWORDS:
        if keyword in lower:
            return keyword
    return None


def _friendly_name(command: str, keyword: str) -> str:
    first = Path(command.split()[0]).name if command.split() else keyword
    if keyword == "claudecodepanel":
        return "Claude Panel"
    if keyword in {"claude", "claude-code", "claude code"}:
        return "Claude Code"
    if keyword == "codex":
        return "Codex CLI"
    if keyword == "opencode":
        return "OpenCode"
    if keyword in {"gemini", "gemini-mcp"}:
        return "Gemini"
    if keyword.startswith("server-"):
        return keyword.replace("server-", "").title() + " MCP"
    if keyword == "mcp-server":
        return "MCP Server"
    if first in {"node", "python", "python3"}:
        return f"{first}/{keyword}"
    return first or keyword


def _family(keyword: str) -> str:
    if keyword in {"claude", "claude-code", "claude code", "claudecodepanel"}:
        return "Claude"
    if keyword == "codex":
        return "Codex"
    if keyword == "opencode":
        return "OpenCode"
    if "mcp" in keyword or keyword.startswith("server-"):
        return "MCP"
    if keyword.startswith("gemini"):
        return "Gemini"
    return "Agent"


def _read_cwd(pid: int) -> str:
    try:
        return os.readlink(f"/proc/{pid}/cwd")
    except OSError:
        return ""


def _read_uptime_seconds(pid: int, now: float | None = None) -> float:
    now = time.time() if now is None else now
    try:
        stat_data = Path(f"/proc/{pid}/stat").read_text().split()
        starttime_ticks = int(stat_data[21])
        uptime = float(Path("/proc/uptime").read_text().split()[0])
    except (OSError, ValueError, IndexError):
        return 0.0
    process_start = now - uptime + (starttime_ticks / CLK_TCK)
    return max(0.0, now - process_start)


def parse_ps_aux_line(
    line: str, *, now: float | None = None, read_proc_metadata: bool = True
) -> ProcessInfo | None:
    parts = line.split(None, 10)
    if len(parts) < 11:
        return None
    try:
        pid = int(parts[1])
        cpu = float(parts[2])
        rss_kb = int(parts[5])
    except ValueError:
        return None
    command = parts[10]
    keyword = _match_keyword(command)
    if keyword is None or pid == os.getpid():
        return None
    uptime_seconds = _read_uptime_seconds(pid, now) if read_proc_metadata else 0.0
    return ProcessInfo(
        pid=pid,
        name=_friendly_name(command, keyword),
        command=command,
        cpu=cpu,
        ram_mb=rss_kb / 1024,
        tty="" if parts[6] == "?" else parts[6],
        cwd=_read_cwd(pid) if read_proc_metadata else "",
        uptime_seconds=uptime_seconds,
        stale=uptime_seconds >= STALE_SECONDS,
        family=_family(keyword),
    )


def scan_processes(*, ps_output: str | None = None) -> list[ProcessInfo]:
    read_proc_metadata = ps_output is None
    if ps_output is None:
        try:
            result = subprocess.run(
                ["ps", "aux", "--no-headers"],
                capture_output=True,
                text=True,
                timeout=5,
            )
        except (OSError, subprocess.TimeoutExpired):
            return []
        if result.returncode != 0:
            return []
        ps_output = result.stdout

    processes = [
        process
        for line in ps_output.splitlines()
        if (process := parse_ps_aux_line(
            line, read_proc_metadata=read_proc_metadata
        )) is not None
    ]
    processes.sort(key=lambda item: (-int(item.stale), -item.ram_mb, item.name))
    return processes


def terminate_process(pid: int) -> None:
    os.kill(pid, signal.SIGTERM)


def format_uptime(seconds: float) -> str:
    if seconds < 60:
        return "<1m"
    minutes = int(seconds // 60)
    if minutes < 60:
        return f"{minutes}m"
    hours = minutes // 60
    if hours < 48:
        return f"{hours}h"
    return f"{hours // 24}d"