agent-usage-manager 0.1.0__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,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ .DS_Store
6
+ agents.local.yaml
7
+ dist/
8
+ build/
9
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-usage-manager
3
+ Version: 0.1.0
4
+ Summary: htop for AI agents — liveness, CPU/mem/GPU usage, and a kill switch for headless agents (openclaw, hermes, ollama, vllm, claude-code).
5
+ Project-URL: Homepage, https://github.com/minglong51/agent-usage-manager
6
+ Project-URL: Repository, https://github.com/minglong51/agent-usage-manager
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai-agents,gpu,llm,monitoring,observability,ollama,vllm
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: fastapi>=0.110
12
+ Requires-Dist: psutil>=5.9
13
+ Requires-Dist: pyyaml>=6.0
14
+ Requires-Dist: uvicorn[standard]>=0.27
15
+ Description-Content-Type: text/markdown
16
+
17
+ # agent-usage-manager
18
+
19
+ A tiny, single-file web dashboard for **headless AI agents** running on a machine —
20
+ OpenClaw, Hermes, Claude Code, Ollama, vLLM, llama.cpp, or anything you name. It
21
+ shows which agents are alive and what they're costing you (CPU, memory, GPU), and
22
+ gives you a **kill button** per agent.
23
+
24
+ No database, no auth layer, no dependencies beyond FastAPI + psutil. Runs on
25
+ macOS and Linux. Meant to be cloned, configured, and run on any node in a fleet.
26
+
27
+ ```
28
+ AGENT PID STATUS CPU % MEM MB GPU MB UPTIME COMMAND ┆
29
+ openclaw +3 48213 ● running 62.4 1840 7320 2h 11m openclaw serve … [kill] [force]
30
+ claude-code +9 73590 ● running 97.4 7630 — 1h 02m claude --chann … [kill] [force]
31
+ hermes 49001 ● running 18.0 512 — 44m hermes worker … [kill] [force]
32
+ ollama 50122 ● running 3.1 9210 14080 6h 02m ollama runner … [kill] [force]
33
+ ```
34
+ (`+N` = child processes rolled up under the agent; CPU/mem/GPU are tree totals.)
35
+
36
+ > A live web UI (auto-refreshing every 3s). The rendered GIF lands here once recorded —
37
+ > see `demo.tape`.
38
+
39
+ ## What it does
40
+
41
+ - **One row per agent.** Agents are grouped by process tree — the spawned children
42
+ of an agent (inference subprocesses, MCP servers, helpers) are rolled up under it
43
+ with a `+N` badge instead of cluttering the list as separate rows.
44
+ - **Liveness** — green dot = running, red = zombie/dead. Status column shows the OS state.
45
+ - **Usage** — CPU %, resident memory (MB), GPU memory (MB, NVIDIA only), and uptime,
46
+ refreshed every 3s. **CPU/mem/GPU are tree totals** — the agent's true cost including
47
+ everything it spawned.
48
+ - **Kill the tree** — `kill` sends SIGTERM to the agent *and its children* (so spawned
49
+ helpers don't leak resources), `force` sends SIGKILL. SIGTERM auto-escalates to
50
+ SIGKILL after 3s. The confirm dialog tells you how many child processes will stop.
51
+
52
+ ## Safety
53
+
54
+ This is the important part — a web page that can kill processes needs guardrails:
55
+
56
+ - **Allowlist only.** Only processes matching a pattern in `agents.yaml` are ever
57
+ listed *or* killable. The kill endpoint re-checks the match server-side before
58
+ sending any signal, so the dashboard can never be used to kill an arbitrary PID.
59
+ - **Protected patterns.** Anything matching `protect:` in `agents.yaml` — plus the
60
+ monitor's own process and PID 1 — shows a disabled, greyed-out kill button and is
61
+ refused server-side.
62
+ - **Secret redaction.** Command lines often carry tokens/keys in env vars or flags
63
+ (`FOO_TOKEN=...`, `--api-key ...`, `sk-...`, `ghp_...`, JWTs). The command column
64
+ redacts these to `***` before they ever reach the browser — safe to screenshot.
65
+ - **Bind local by default.** It listens on `127.0.0.1`. Don't expose it to a network
66
+ without putting auth in front of it (reverse proxy + basic auth, SSH tunnel, etc.) —
67
+ it has no built-in authentication.
68
+
69
+ ## Quick start
70
+
71
+ Run it without installing anything (needs [`uv`](https://github.com/astral-sh/uv)):
72
+
73
+ ```bash
74
+ uvx agent-usage-manager
75
+ # open http://127.0.0.1:8765
76
+ ```
77
+
78
+ Or install it:
79
+
80
+ ```bash
81
+ pipx install agent-usage-manager # or: pip install agent-usage-manager
82
+ agent-usage-manager --port 8765
83
+ ```
84
+
85
+ From a clone (for hacking on it):
86
+
87
+ ```bash
88
+ git clone <this-repo> && cd agent-usage-manager
89
+ ./run.sh # venv + editable install, serves on :8765
90
+ ```
91
+
92
+ Flags: `--host`, `--port`, `--config /path/to/agents.yaml`.
93
+
94
+ ## Configure which processes are "agents"
95
+
96
+ Edit `agents.yaml`:
97
+
98
+ ```yaml
99
+ agents:
100
+ - label: openclaw # shown as the badge in the UI
101
+ match: openclaw # case-insensitive substring of the command line
102
+ - label: hermes
103
+ match: hermes
104
+ - label: claude-code
105
+ match: "claude(\\s|$|-code)"
106
+ regex: true # treat `match` as a regex instead of substring
107
+
108
+ protect: # never killable, even if matched above
109
+ - uvicorn
110
+ ```
111
+
112
+ A process matches if the pattern hits its **full command line** or its process name.
113
+ Point at a different file with `AGENTS_CONFIG=/path/to/agents.yaml`.
114
+
115
+ ## GPU notes
116
+
117
+ Per-process GPU memory comes from `nvidia-smi` when it's on `PATH` (Linux / NVIDIA).
118
+ **Apple Silicon has no per-process GPU accounting API**, so the GPU column stays blank
119
+ on Macs — CPU and memory are the meaningful resource signals there.
120
+
121
+ ## API
122
+
123
+ - `GET /api/agents` → `{ agents: [...], host, cpu_count, ts }`
124
+ - `POST /api/kill/{pid}?force=false` → SIGTERM (or SIGKILL with `force=true`)
125
+
126
+ ## Run as a service
127
+
128
+ Linux (systemd), `~/.config/systemd/user/agent-usage-manager.service`:
129
+
130
+ ```ini
131
+ [Unit]
132
+ Description=agent usage manager
133
+ [Service]
134
+ ExecStart=%h/agent-usage-manager/.venv/bin/uvicorn app:app --port 8765
135
+ WorkingDirectory=%h/agent-usage-manager
136
+ Restart=on-failure
137
+ [Install]
138
+ WantedBy=default.target
139
+ ```
140
+
141
+ ```bash
142
+ systemctl --user enable --now agent-usage-manager
143
+ ```
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,131 @@
1
+ # agent-usage-manager
2
+
3
+ A tiny, single-file web dashboard for **headless AI agents** running on a machine —
4
+ OpenClaw, Hermes, Claude Code, Ollama, vLLM, llama.cpp, or anything you name. It
5
+ shows which agents are alive and what they're costing you (CPU, memory, GPU), and
6
+ gives you a **kill button** per agent.
7
+
8
+ No database, no auth layer, no dependencies beyond FastAPI + psutil. Runs on
9
+ macOS and Linux. Meant to be cloned, configured, and run on any node in a fleet.
10
+
11
+ ```
12
+ AGENT PID STATUS CPU % MEM MB GPU MB UPTIME COMMAND ┆
13
+ openclaw +3 48213 ● running 62.4 1840 7320 2h 11m openclaw serve … [kill] [force]
14
+ claude-code +9 73590 ● running 97.4 7630 — 1h 02m claude --chann … [kill] [force]
15
+ hermes 49001 ● running 18.0 512 — 44m hermes worker … [kill] [force]
16
+ ollama 50122 ● running 3.1 9210 14080 6h 02m ollama runner … [kill] [force]
17
+ ```
18
+ (`+N` = child processes rolled up under the agent; CPU/mem/GPU are tree totals.)
19
+
20
+ > A live web UI (auto-refreshing every 3s). The rendered GIF lands here once recorded —
21
+ > see `demo.tape`.
22
+
23
+ ## What it does
24
+
25
+ - **One row per agent.** Agents are grouped by process tree — the spawned children
26
+ of an agent (inference subprocesses, MCP servers, helpers) are rolled up under it
27
+ with a `+N` badge instead of cluttering the list as separate rows.
28
+ - **Liveness** — green dot = running, red = zombie/dead. Status column shows the OS state.
29
+ - **Usage** — CPU %, resident memory (MB), GPU memory (MB, NVIDIA only), and uptime,
30
+ refreshed every 3s. **CPU/mem/GPU are tree totals** — the agent's true cost including
31
+ everything it spawned.
32
+ - **Kill the tree** — `kill` sends SIGTERM to the agent *and its children* (so spawned
33
+ helpers don't leak resources), `force` sends SIGKILL. SIGTERM auto-escalates to
34
+ SIGKILL after 3s. The confirm dialog tells you how many child processes will stop.
35
+
36
+ ## Safety
37
+
38
+ This is the important part — a web page that can kill processes needs guardrails:
39
+
40
+ - **Allowlist only.** Only processes matching a pattern in `agents.yaml` are ever
41
+ listed *or* killable. The kill endpoint re-checks the match server-side before
42
+ sending any signal, so the dashboard can never be used to kill an arbitrary PID.
43
+ - **Protected patterns.** Anything matching `protect:` in `agents.yaml` — plus the
44
+ monitor's own process and PID 1 — shows a disabled, greyed-out kill button and is
45
+ refused server-side.
46
+ - **Secret redaction.** Command lines often carry tokens/keys in env vars or flags
47
+ (`FOO_TOKEN=...`, `--api-key ...`, `sk-...`, `ghp_...`, JWTs). The command column
48
+ redacts these to `***` before they ever reach the browser — safe to screenshot.
49
+ - **Bind local by default.** It listens on `127.0.0.1`. Don't expose it to a network
50
+ without putting auth in front of it (reverse proxy + basic auth, SSH tunnel, etc.) —
51
+ it has no built-in authentication.
52
+
53
+ ## Quick start
54
+
55
+ Run it without installing anything (needs [`uv`](https://github.com/astral-sh/uv)):
56
+
57
+ ```bash
58
+ uvx agent-usage-manager
59
+ # open http://127.0.0.1:8765
60
+ ```
61
+
62
+ Or install it:
63
+
64
+ ```bash
65
+ pipx install agent-usage-manager # or: pip install agent-usage-manager
66
+ agent-usage-manager --port 8765
67
+ ```
68
+
69
+ From a clone (for hacking on it):
70
+
71
+ ```bash
72
+ git clone <this-repo> && cd agent-usage-manager
73
+ ./run.sh # venv + editable install, serves on :8765
74
+ ```
75
+
76
+ Flags: `--host`, `--port`, `--config /path/to/agents.yaml`.
77
+
78
+ ## Configure which processes are "agents"
79
+
80
+ Edit `agents.yaml`:
81
+
82
+ ```yaml
83
+ agents:
84
+ - label: openclaw # shown as the badge in the UI
85
+ match: openclaw # case-insensitive substring of the command line
86
+ - label: hermes
87
+ match: hermes
88
+ - label: claude-code
89
+ match: "claude(\\s|$|-code)"
90
+ regex: true # treat `match` as a regex instead of substring
91
+
92
+ protect: # never killable, even if matched above
93
+ - uvicorn
94
+ ```
95
+
96
+ A process matches if the pattern hits its **full command line** or its process name.
97
+ Point at a different file with `AGENTS_CONFIG=/path/to/agents.yaml`.
98
+
99
+ ## GPU notes
100
+
101
+ Per-process GPU memory comes from `nvidia-smi` when it's on `PATH` (Linux / NVIDIA).
102
+ **Apple Silicon has no per-process GPU accounting API**, so the GPU column stays blank
103
+ on Macs — CPU and memory are the meaningful resource signals there.
104
+
105
+ ## API
106
+
107
+ - `GET /api/agents` → `{ agents: [...], host, cpu_count, ts }`
108
+ - `POST /api/kill/{pid}?force=false` → SIGTERM (or SIGKILL with `force=true`)
109
+
110
+ ## Run as a service
111
+
112
+ Linux (systemd), `~/.config/systemd/user/agent-usage-manager.service`:
113
+
114
+ ```ini
115
+ [Unit]
116
+ Description=agent usage manager
117
+ [Service]
118
+ ExecStart=%h/agent-usage-manager/.venv/bin/uvicorn app:app --port 8765
119
+ WorkingDirectory=%h/agent-usage-manager
120
+ Restart=on-failure
121
+ [Install]
122
+ WantedBy=default.target
123
+ ```
124
+
125
+ ```bash
126
+ systemctl --user enable --now agent-usage-manager
127
+ ```
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,34 @@
1
+ # Which processes count as "agents". A process matches if the pattern hits its
2
+ # executable name + first few arguments (case-insensitive substring or regex) —
3
+ # NOT the entire command line, so a process that merely mentions an agent name
4
+ # deep in its args (e.g. inside a system prompt) is not misclassified.
5
+ # Only processes that match here can be listed AND killed — the kill endpoint
6
+ # re-checks the match before sending a signal, so the dashboard can never be
7
+ # used to kill an arbitrary unrelated PID.
8
+ #
9
+ # Matched processes are grouped by process tree: one row per agent (the root),
10
+ # with child processes rolled up. CPU/mem/GPU are tree totals, and "kill" stops
11
+ # the whole tree (root + children) so spawned helpers don't leak resources.
12
+ agents:
13
+ - label: openclaw
14
+ match: openclaw
15
+ - label: hermes
16
+ match: hermes
17
+ - label: claude-code
18
+ match: "claude(\\s|$|-code)"
19
+ regex: true
20
+ - label: ollama
21
+ match: ollama
22
+ - label: vllm
23
+ match: vllm
24
+ - label: llama.cpp
25
+ match: llama-server
26
+
27
+ # Patterns that must NEVER be killed even if they match an agent pattern above.
28
+ # Self (this monitor's own PID) and PID 1 are always protected in code.
29
+ protect:
30
+ - agent-usage-manager
31
+ - uvicorn
32
+
33
+ # GPU sampling: nvidia-smi is used automatically when present (Linux/NVIDIA).
34
+ # On Apple Silicon there is no per-process GPU API, so the GPU column is blank.
@@ -0,0 +1,375 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import psutil
13
+ import yaml
14
+ from fastapi import FastAPI, HTTPException
15
+ from fastapi.responses import FileResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from pydantic import BaseModel
18
+
19
+ BASE = Path(__file__).parent
20
+
21
+
22
+ def _resolve_config() -> Path:
23
+ env = os.environ.get("AGENTS_CONFIG")
24
+ if env:
25
+ return Path(env)
26
+ cwd_cfg = Path.cwd() / "agents.yaml"
27
+ if cwd_cfg.exists():
28
+ return cwd_cfg
29
+ return BASE / "agents.yaml"
30
+
31
+
32
+ CONFIG_PATH = _resolve_config()
33
+
34
+
35
+ class Matcher:
36
+ def __init__(self, label: str, pattern: str, is_regex: bool) -> None:
37
+ self.label = label
38
+ self.is_regex = is_regex
39
+ self.raw = pattern
40
+ self.rx = re.compile(pattern, re.IGNORECASE) if is_regex else None
41
+ self.lower = pattern.lower()
42
+
43
+ def matches(self, text: str) -> bool:
44
+ if self.rx is not None:
45
+ return self.rx.search(text) is not None
46
+ return self.lower in text.lower()
47
+
48
+
49
+ def load_config() -> tuple[list[Matcher], list[str]]:
50
+ data = yaml.safe_load(CONFIG_PATH.read_text())
51
+ matchers = [
52
+ Matcher(a["label"], a["match"], bool(a.get("regex", False)))
53
+ for a in data.get("agents", [])
54
+ ]
55
+ protect = [p.lower() for p in data.get("protect", [])]
56
+ return matchers, protect
57
+
58
+
59
+ MATCHERS, PROTECT = load_config()
60
+ SELF_PID = os.getpid()
61
+
62
+
63
+ _SECRET_KV = re.compile(
64
+ r"(?i)([\w.-]*(?:token|key|secret|password|passwd|api[_-]?key|auth)[\w.-]*\s*[=:]\s*)\S+"
65
+ )
66
+ _SECRET_FLAG = re.compile(
67
+ r"(?i)(--?(?:token|key|secret|password|api[_-]?key|auth)\S*\s+)\S+"
68
+ )
69
+ _SECRET_VALUE = re.compile(
70
+ r"\b(sk-[A-Za-z0-9]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|xox[bap]-[A-Za-z0-9-]{8,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,})"
71
+ )
72
+
73
+
74
+ def _redact(text: str) -> str:
75
+ text = _SECRET_KV.sub(r"\1***", text)
76
+ text = _SECRET_FLAG.sub(r"\1***", text)
77
+ text = _SECRET_VALUE.sub("***", text)
78
+ return text
79
+
80
+
81
+ def _cmdline(proc: psutil.Process) -> str:
82
+ try:
83
+ raw = " ".join(proc.cmdline()) or proc.name()
84
+ except (psutil.AccessDenied, psutil.ZombieProcess, psutil.NoSuchProcess):
85
+ try:
86
+ raw = proc.name()
87
+ except psutil.Error:
88
+ return ""
89
+ return _redact(raw)
90
+
91
+
92
+ def _match_target(proc: psutil.Process) -> str:
93
+ """Text used for agent matching: the executable basename + first few args.
94
+
95
+ Deliberately NOT the full command line — a long embedded argument (e.g. a
96
+ system prompt that happens to contain the word "claude") must not cause a
97
+ parent/wrapper process to be misclassified as an agent.
98
+ """
99
+ try:
100
+ argv = proc.cmdline()
101
+ except (psutil.AccessDenied, psutil.ZombieProcess, psutil.NoSuchProcess):
102
+ argv = []
103
+ if argv:
104
+ head = argv[:4]
105
+ if head[0]:
106
+ head[0] = os.path.basename(head[0])
107
+ return " ".join(head)
108
+ try:
109
+ return proc.name()
110
+ except psutil.Error:
111
+ return ""
112
+
113
+
114
+ def _label_for(text: str) -> Optional[str]:
115
+ for m in MATCHERS:
116
+ if m.matches(text):
117
+ return m.label
118
+ return None
119
+
120
+
121
+ def _is_protected(text: str, pid: int) -> bool:
122
+ if pid in (SELF_PID, 1):
123
+ return True
124
+ low = text.lower()
125
+ return any(p in low for p in PROTECT)
126
+
127
+
128
+ def _gpu_by_pid() -> dict[int, float]:
129
+ """Best-effort per-process GPU memory (MiB) via nvidia-smi. Empty on macOS."""
130
+ if not shutil.which("nvidia-smi"):
131
+ return {}
132
+ try:
133
+ out = subprocess.run(
134
+ [
135
+ "nvidia-smi",
136
+ "--query-compute-apps=pid,used_memory",
137
+ "--format=csv,noheader,nounits",
138
+ ],
139
+ capture_output=True,
140
+ text=True,
141
+ timeout=4,
142
+ ).stdout
143
+ except (subprocess.SubprocessError, OSError):
144
+ return {}
145
+ result: dict[int, float] = {}
146
+ for line in out.strip().splitlines():
147
+ parts = [p.strip() for p in line.split(",")]
148
+ if len(parts) == 2 and parts[0].isdigit():
149
+ result[int(parts[0])] = float(parts[1])
150
+ return result
151
+
152
+
153
+ app = FastAPI(title="agent-usage-manager")
154
+
155
+ # Persistent Process handles so cpu_percent() reports usage since the last poll.
156
+ _handles: dict[int, psutil.Process] = {}
157
+
158
+
159
+ class Agent(BaseModel):
160
+ pid: int
161
+ label: str
162
+ name: str
163
+ cmdline: str
164
+ status: str
165
+ alive: bool
166
+ cpu_percent: float
167
+ mem_mb: float
168
+ gpu_mem_mb: Optional[float]
169
+ uptime_s: float
170
+ child_count: int
171
+ protected: bool
172
+
173
+
174
+ def _collect() -> tuple[dict, dict, dict, dict]:
175
+ """One pass over all processes.
176
+
177
+ Returns (meta, children, label_of, procmap):
178
+ meta[pid] = {ppid, name, status, ct}
179
+ children[ppid]= [pid, ...]
180
+ label_of[pid] = matcher label (only present for matched processes)
181
+ procmap[pid] = the psutil.Process from this poll
182
+ """
183
+ meta: dict[int, dict] = {}
184
+ children: dict[int, list[int]] = {}
185
+ label_of: dict[int, str] = {}
186
+ procmap: dict[int, psutil.Process] = {}
187
+ for proc in psutil.process_iter(["pid", "ppid", "name", "status", "create_time"]):
188
+ pid = proc.info["pid"]
189
+ ppid = proc.info.get("ppid") or 0
190
+ name = proc.info.get("name") or ""
191
+ meta[pid] = {
192
+ "ppid": ppid,
193
+ "name": name,
194
+ "status": proc.info.get("status") or "?",
195
+ "ct": proc.info.get("create_time"),
196
+ }
197
+ children.setdefault(ppid, []).append(pid)
198
+ procmap[pid] = proc
199
+ label = _label_for(_match_target(proc)) or _label_for(name)
200
+ if label:
201
+ label_of[pid] = label
202
+ return meta, children, label_of, procmap
203
+
204
+
205
+ def _ancestors(pid: int, meta: dict):
206
+ seen: set[int] = set()
207
+ cur = meta.get(pid, {}).get("ppid", 0)
208
+ while cur and cur not in seen:
209
+ seen.add(cur)
210
+ yield cur
211
+ cur = meta.get(cur, {}).get("ppid", 0)
212
+
213
+
214
+ def _descendants(root: int, children: dict) -> list[int]:
215
+ out: list[int] = []
216
+ stack = list(children.get(root, []))
217
+ seen: set[int] = set()
218
+ while stack:
219
+ p = stack.pop()
220
+ if p in seen:
221
+ continue
222
+ seen.add(p)
223
+ out.append(p)
224
+ stack.extend(children.get(p, []))
225
+ return out
226
+
227
+
228
+ def _cpu_mem(pid: int, procmap: dict) -> tuple[float, float]:
229
+ handle = _handles.get(pid)
230
+ if handle is None or handle.pid != pid:
231
+ handle = procmap.get(pid)
232
+ if handle is None:
233
+ try:
234
+ handle = psutil.Process(pid)
235
+ except psutil.NoSuchProcess:
236
+ return 0.0, 0.0
237
+ _handles[pid] = handle
238
+ try:
239
+ handle.cpu_percent(None)
240
+ except psutil.Error:
241
+ pass
242
+ try:
243
+ return handle.cpu_percent(None), handle.memory_info().rss / (1024 * 1024)
244
+ except psutil.Error:
245
+ return 0.0, 0.0
246
+
247
+
248
+ @app.get("/api/agents")
249
+ def list_agents() -> dict:
250
+ gpu = _gpu_by_pid()
251
+ now = time.time()
252
+ meta, children, label_of, procmap = _collect()
253
+ matched = set(label_of)
254
+
255
+ # An agent's "root" is a matched process with no matched ancestor; any matched
256
+ # descendant is rolled up into it rather than shown as its own row.
257
+ roots = [
258
+ pid
259
+ for pid in matched
260
+ if not any(a in matched for a in _ancestors(pid, meta))
261
+ ]
262
+
263
+ agents: list[Agent] = []
264
+ for root in roots:
265
+ subtree = [root] + _descendants(root, children)
266
+ cpu = mem = gpu_sum = 0.0
267
+ has_gpu = False
268
+ for p in subtree:
269
+ c, m = _cpu_mem(p, procmap)
270
+ cpu += c
271
+ mem += m
272
+ if p in gpu:
273
+ gpu_sum += gpu[p]
274
+ has_gpu = True
275
+ info = meta[root]
276
+ rproc = procmap.get(root)
277
+ cmd = _cmdline(rproc) if rproc else info["name"]
278
+ ct = info["ct"] or now
279
+ agents.append(
280
+ Agent(
281
+ pid=root,
282
+ label=label_of[root],
283
+ name=info["name"],
284
+ cmdline=cmd[:300],
285
+ status=info["status"],
286
+ alive=info["status"] != psutil.STATUS_ZOMBIE,
287
+ cpu_percent=round(cpu, 1),
288
+ mem_mb=round(mem, 1),
289
+ gpu_mem_mb=round(gpu_sum, 1) if has_gpu else None,
290
+ uptime_s=round(now - ct, 0),
291
+ child_count=len(subtree) - 1,
292
+ protected=_is_protected(
293
+ _match_target(rproc) if rproc else info["name"], root
294
+ ),
295
+ )
296
+ )
297
+
298
+ live = set(meta)
299
+ for pid in list(_handles):
300
+ if pid not in live:
301
+ _handles.pop(pid, None)
302
+
303
+ agents.sort(key=lambda a: a.cpu_percent, reverse=True)
304
+ return {
305
+ "agents": [a.model_dump() for a in agents],
306
+ "host": psutil.os.uname().nodename if hasattr(psutil.os, "uname") else "",
307
+ "cpu_count": psutil.cpu_count(),
308
+ "ts": now,
309
+ }
310
+
311
+
312
+ def _signal_tree(root_pid: int, sig: signal.Signals) -> list[psutil.Process]:
313
+ """Send `sig` to the root and every descendant, skipping self/PID 1/protected."""
314
+ _, children, _, procmap = _collect()
315
+ victims = [root_pid] + _descendants(root_pid, children)
316
+ signaled: list[psutil.Process] = []
317
+ for p in victims:
318
+ if p in (SELF_PID, 1):
319
+ continue
320
+ proc = procmap.get(p)
321
+ if proc is None:
322
+ try:
323
+ proc = psutil.Process(p)
324
+ except psutil.NoSuchProcess:
325
+ continue
326
+ try:
327
+ if _is_protected(_match_target(proc), p):
328
+ continue
329
+ proc.send_signal(sig)
330
+ signaled.append(proc)
331
+ except psutil.Error:
332
+ pass
333
+ return signaled
334
+
335
+
336
+ @app.post("/api/kill/{pid}")
337
+ def kill_agent(pid: int, force: bool = False) -> dict:
338
+ try:
339
+ proc = psutil.Process(pid)
340
+ except psutil.NoSuchProcess:
341
+ raise HTTPException(404, f"PID {pid} not found")
342
+
343
+ target = _match_target(proc)
344
+ if _label_for(target) is None and _label_for(proc.name()) is None:
345
+ raise HTTPException(403, f"PID {pid} is not a recognized agent — refusing")
346
+ if _is_protected(target, pid):
347
+ raise HTTPException(403, f"PID {pid} is protected — refusing")
348
+
349
+ sig = signal.SIGKILL if force else signal.SIGTERM
350
+ signaled = _signal_tree(pid, sig)
351
+ gone, alive = psutil.wait_procs(signaled, timeout=3)
352
+ if alive and not force:
353
+ for p in alive:
354
+ try:
355
+ p.send_signal(signal.SIGKILL)
356
+ except psutil.Error:
357
+ pass
358
+ more_gone, alive = psutil.wait_procs(alive, timeout=3)
359
+ gone += more_gone
360
+
361
+ return {
362
+ "pid": pid,
363
+ "result": "terminated" if not alive else "signal sent, some still running",
364
+ "signal": sig.name,
365
+ "killed": len(gone),
366
+ "still_running": len(alive),
367
+ }
368
+
369
+
370
+ @app.get("/")
371
+ def index() -> FileResponse:
372
+ return FileResponse(BASE / "static" / "index.html")
373
+
374
+
375
+ app.mount("/static", StaticFiles(directory=BASE / "static"), name="static")
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+
6
+ import uvicorn
7
+
8
+
9
+ def main() -> None:
10
+ parser = argparse.ArgumentParser(
11
+ prog="agent-usage-manager",
12
+ description="Web dashboard for headless AI agents: liveness, CPU/mem/GPU, kill.",
13
+ )
14
+ parser.add_argument("--host", default=os.environ.get("HOST", "127.0.0.1"))
15
+ parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8765")))
16
+ parser.add_argument(
17
+ "--config",
18
+ default=None,
19
+ help="Path to agents.yaml (default: ./agents.yaml, else bundled).",
20
+ )
21
+ args = parser.parse_args()
22
+ if args.config:
23
+ os.environ["AGENTS_CONFIG"] = args.config
24
+
25
+ print(f"agent-usage-manager → http://{args.host}:{args.port}")
26
+ uvicorn.run("agent_usage_manager.app:app", host=args.host, port=args.port)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
@@ -0,0 +1,129 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>agent usage manager</title>
7
+ <style>
8
+ :root { color-scheme: dark; }
9
+ body { font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
10
+ margin: 0; background: #0d1117; color: #c9d1d9; }
11
+ header { padding: 14px 20px; border-bottom: 1px solid #21262d;
12
+ display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
13
+ h1 { font-size: 16px; margin: 0; }
14
+ .meta { color: #8b949e; font-size: 12px; }
15
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
16
+ margin-right: 6px; vertical-align: middle; }
17
+ .live { background: #3fb950; } .dead { background: #f85149; }
18
+ table { width: 100%; border-collapse: collapse; }
19
+ th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #161b22;
20
+ white-space: nowrap; }
21
+ th { color: #8b949e; font-weight: 600; font-size: 12px; text-transform: uppercase;
22
+ letter-spacing: .04em; }
23
+ td.cmd { white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
24
+ max-width: 380px; color: #8b949e; }
25
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
26
+ .label { background: #1f6feb33; color: #79c0ff; padding: 1px 7px;
27
+ border-radius: 10px; font-size: 12px; }
28
+ .kids { color: #8b949e; font-size: 11px; margin-left: 5px; cursor: help; }
29
+ .bar { height: 5px; background: #21262d; border-radius: 3px; margin-top: 3px; }
30
+ .bar > i { display: block; height: 100%; border-radius: 3px; background: #d29922; }
31
+ button.kill { background: #da3633; color: #fff; border: 0; padding: 5px 12px;
32
+ border-radius: 6px; cursor: pointer; font: inherit; }
33
+ button.kill:hover { background: #f85149; }
34
+ button.kill:disabled { background: #30363d; color: #6e7681; cursor: not-allowed; }
35
+ .empty { padding: 40px 20px; color: #8b949e; }
36
+ .err { color: #f85149; }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <header>
41
+ <h1>agent usage manager</h1>
42
+ <span class="meta" id="meta">loading…</span>
43
+ <span class="meta" id="err"></span>
44
+ </header>
45
+ <table>
46
+ <thead>
47
+ <tr>
48
+ <th>agent</th><th>pid</th><th>status</th>
49
+ <th class="num">cpu %</th><th class="num">mem MB</th>
50
+ <th class="num">gpu MB</th><th class="num">uptime</th>
51
+ <th>command</th><th></th>
52
+ </tr>
53
+ </thead>
54
+ <tbody id="rows"></tbody>
55
+ </table>
56
+ <div class="empty" id="empty" hidden>No matching agents running.</div>
57
+
58
+ <script>
59
+ const rows = document.getElementById("rows");
60
+ const meta = document.getElementById("meta");
61
+ const errEl = document.getElementById("err");
62
+ const empty = document.getElementById("empty");
63
+
64
+ function dur(s) {
65
+ s = Math.max(0, s|0);
66
+ const d = Math.floor(s/86400), h = Math.floor(s%86400/3600),
67
+ m = Math.floor(s%3600/60);
68
+ if (d) return d+"d "+h+"h";
69
+ if (h) return h+"h "+m+"m";
70
+ if (m) return m+"m";
71
+ return s+"s";
72
+ }
73
+
74
+ async function kill(pid, force, children) {
75
+ const verb = force ? "FORCE KILL (SIGKILL)" : "kill (SIGTERM)";
76
+ const kids = children > 0
77
+ ? ` and its ${children} child process${children > 1 ? "es" : ""}`
78
+ : "";
79
+ if (!confirm(verb + " PID " + pid + kids + "?")) return;
80
+ try {
81
+ const r = await fetch(`/api/kill/${pid}?force=${force}`, {method: "POST"});
82
+ const j = await r.json();
83
+ if (!r.ok) throw new Error(j.detail || r.status);
84
+ await refresh();
85
+ } catch (e) { errEl.textContent = "kill failed: " + e.message; }
86
+ }
87
+
88
+ async function refresh() {
89
+ let data;
90
+ try {
91
+ const r = await fetch("/api/agents");
92
+ data = await r.json();
93
+ errEl.textContent = "";
94
+ } catch (e) { errEl.textContent = "fetch failed: " + e.message; return; }
95
+
96
+ const a = data.agents;
97
+ meta.textContent = `${data.host || "host"} · ${data.cpu_count} cpus · ${a.length} agents · ${new Date(data.ts*1000).toLocaleTimeString()}`;
98
+ empty.hidden = a.length > 0;
99
+ rows.innerHTML = a.map(x => {
100
+ const cpuPct = Math.min(100, x.cpu_percent);
101
+ const protectedNote = x.protected ? ' title="protected — cannot be killed"' : "";
102
+ const kids = x.child_count > 0
103
+ ? ` <span class="kids" title="${x.child_count} child process(es) — CPU/mem/GPU shown are tree totals; kill stops the whole tree">+${x.child_count}</span>`
104
+ : "";
105
+ return `<tr>
106
+ <td><span class="label">${x.label}</span>${kids}</td>
107
+ <td>${x.pid}</td>
108
+ <td><span class="dot ${x.alive?'live':'dead'}"></span>${x.status}</td>
109
+ <td class="num">${x.cpu_percent.toFixed(1)}
110
+ <div class="bar"><i style="width:${cpuPct}%"></i></div></td>
111
+ <td class="num">${x.mem_mb.toFixed(0)}</td>
112
+ <td class="num">${x.gpu_mem_mb==null?'—':x.gpu_mem_mb.toFixed(0)}</td>
113
+ <td class="num">${dur(x.uptime_s)}</td>
114
+ <td class="cmd" title="${x.cmdline.replace(/"/g,'&quot;')}">${x.cmdline}</td>
115
+ <td>
116
+ <button class="kill" ${x.protected?'disabled':''}${protectedNote}
117
+ onclick="kill(${x.pid}, false, ${x.child_count})">kill</button>
118
+ <button class="kill" ${x.protected?'disabled':''}
119
+ onclick="kill(${x.pid}, true, ${x.child_count})">force</button>
120
+ </td>
121
+ </tr>`;
122
+ }).join("");
123
+ }
124
+
125
+ refresh();
126
+ setInterval(refresh, 3000);
127
+ </script>
128
+ </body>
129
+ </html>
@@ -0,0 +1,21 @@
1
+ # Generate the README GIF with charm's `vhs`: vhs demo.tape -> demo.gif
2
+ # Install vhs: brew install vhs (or see https://github.com/charmbracelet/vhs)
3
+ #
4
+ # This records a terminal launching the tool, then you screen-capture the
5
+ # browser separately — OR point vhs at a headless browser. Simplest path:
6
+ # run the tool, open the page, and use a screen recorder for the web UI.
7
+ # This tape covers the terminal half (the "one line to run it" moment).
8
+
9
+ Output demo.gif
10
+ Set FontSize 18
11
+ Set Width 1100
12
+ Set Height 420
13
+ Set Theme "Dracula"
14
+ Set Padding 18
15
+
16
+ Type "uvx agent-usage-manager"
17
+ Sleep 600ms
18
+ Enter
19
+ Sleep 2.5s
20
+ Type "# open http://127.0.0.1:8765 → see every agent's CPU/RAM/GPU, click kill"
21
+ Sleep 3s
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agent-usage-manager"
7
+ version = "0.1.0"
8
+ description = "htop for AI agents — liveness, CPU/mem/GPU usage, and a kill switch for headless agents (openclaw, hermes, ollama, vllm, claude-code)."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ keywords = ["ai-agents", "monitoring", "gpu", "ollama", "vllm", "llm", "observability"]
13
+ dependencies = [
14
+ "fastapi>=0.110",
15
+ "uvicorn[standard]>=0.27",
16
+ "psutil>=5.9",
17
+ "pyyaml>=6.0",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/minglong51/agent-usage-manager"
22
+ Repository = "https://github.com/minglong51/agent-usage-manager"
23
+
24
+ [project.scripts]
25
+ agent-usage-manager = "agent_usage_manager.cli:main"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["agent_usage_manager"]
@@ -0,0 +1,4 @@
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.27
3
+ psutil>=5.9
4
+ pyyaml>=6.0
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd "$(dirname "$0")"
4
+
5
+ if [ ! -d .venv ]; then
6
+ python3 -m venv .venv
7
+ ./.venv/bin/pip install -q --upgrade pip
8
+ ./.venv/bin/pip install -q -e .
9
+ fi
10
+
11
+ HOST="${HOST:-127.0.0.1}" PORT="${PORT:-8765}" ./.venv/bin/agent-usage-manager "$@"