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.
- agent_usage_manager-0.1.0/.gitignore +9 -0
- agent_usage_manager-0.1.0/LICENSE +21 -0
- agent_usage_manager-0.1.0/PKG-INFO +147 -0
- agent_usage_manager-0.1.0/README.md +131 -0
- agent_usage_manager-0.1.0/agent_usage_manager/__init__.py +0 -0
- agent_usage_manager-0.1.0/agent_usage_manager/agents.yaml +34 -0
- agent_usage_manager-0.1.0/agent_usage_manager/app.py +375 -0
- agent_usage_manager-0.1.0/agent_usage_manager/cli.py +30 -0
- agent_usage_manager-0.1.0/agent_usage_manager/static/index.html +129 -0
- agent_usage_manager-0.1.0/demo.tape +21 -0
- agent_usage_manager-0.1.0/pyproject.toml +28 -0
- agent_usage_manager-0.1.0/requirements.txt +4 -0
- agent_usage_manager-0.1.0/run.sh +11 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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,'"')}">${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,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 "$@"
|