agentmon 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/exe/agentmon +18 -0
- data/lib/agentmon/commands/cli_options.rb +21 -0
- data/lib/agentmon/commands/footprint.rb +81 -0
- data/lib/agentmon/commands/memory.rb +110 -0
- data/lib/agentmon/commands/record.rb +118 -0
- data/lib/agentmon/commands/report.rb +127 -0
- data/lib/agentmon/commands/sessions.rb +98 -0
- data/lib/agentmon/commands/top.rb +87 -0
- data/lib/agentmon/commands/tree.rb +96 -0
- data/lib/agentmon/darwin.rb +235 -0
- data/lib/agentmon/engine.rb +115 -0
- data/lib/agentmon/focus.rb +88 -0
- data/lib/agentmon/metrics/memory.rb +76 -0
- data/lib/agentmon/metrics/network.rb +245 -0
- data/lib/agentmon/metrics/pressure_drivers.rb +57 -0
- data/lib/agentmon/metrics/process_rates.rb +65 -0
- data/lib/agentmon/metrics/process_rows.rb +41 -0
- data/lib/agentmon/metrics/session_ledger.rb +160 -0
- data/lib/agentmon/metrics/session_memory.rb +84 -0
- data/lib/agentmon/metrics/session_names.rb +169 -0
- data/lib/agentmon/metrics/sessions.rb +80 -0
- data/lib/agentmon/metrics/system.rb +114 -0
- data/lib/agentmon/model.rb +263 -0
- data/lib/agentmon/probes/cwd.rb +32 -0
- data/lib/agentmon/probes/memory.rb +139 -0
- data/lib/agentmon/probes/network.rb +326 -0
- data/lib/agentmon/probes/processes.rb +72 -0
- data/lib/agentmon/probes/system.rb +134 -0
- data/lib/agentmon/program.rb +55 -0
- data/lib/agentmon/reading.rb +83 -0
- data/lib/agentmon/recorders/memory.rb +19 -0
- data/lib/agentmon/recorders/sessions.rb +40 -0
- data/lib/agentmon/registry.rb +155 -0
- data/lib/agentmon/sampler.rb +50 -0
- data/lib/agentmon/store.rb +86 -0
- data/lib/agentmon/ui/connections.rb +75 -0
- data/lib/agentmon/ui/interaction.rb +94 -0
- data/lib/agentmon/ui/memory.rb +125 -0
- data/lib/agentmon/ui/process_actions.rb +49 -0
- data/lib/agentmon/ui/process_detail.rb +148 -0
- data/lib/agentmon/ui/process_network.rb +13 -0
- data/lib/agentmon/ui/process_scopes.rb +32 -0
- data/lib/agentmon/ui/process_waits.rb +99 -0
- data/lib/agentmon/ui/processes.rb +42 -0
- data/lib/agentmon/ui/session_focus.rb +126 -0
- data/lib/agentmon/ui/session_memory.rb +78 -0
- data/lib/agentmon/ui/sessions.rb +130 -0
- data/lib/agentmon/ui/theme.rb +49 -0
- data/lib/agentmon/ui.rb +80 -0
- data/lib/agentmon/version.rb +5 -0
- data/lib/agentmon/views/dense.rb +157 -0
- data/lib/agentmon/views/history.rb +62 -0
- data/lib/agentmon/views/signals.rb +279 -0
- data/lib/agentmon/views/visual.rb +172 -0
- data/lib/agentmon/views/widgets.rb +352 -0
- data/lib/agentmon/views.rb +875 -0
- data/lib/agentmon.rb +38 -0
- metadata +135 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cf488d280dd14af8cfdf2a7c576f0894b6c64749ac1249ece9282df402ff8a17
|
|
4
|
+
data.tar.gz: fe30f9f22652a83a2412922082bff73a832fe8c4ad73e09d5345f3d0f968531a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4e4f558fa84438c7c956d7e2b903b03d25de57b3fd6f3602dd7f21c4be7e34017cda1df1023309a9e01ba0d89ba29b14af53ed00fb3abcd43f8bfe1ae0e9490e
|
|
7
|
+
data.tar.gz: 767da3880bb80429d9c0d960adff29ed252d368e9ee5581f4dbee8ddac36899946b87bde5473b54b22da7c172302018df15b8b0b48fa19a3a8dfa5ebba29cee9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
First release.
|
|
8
|
+
|
|
9
|
+
- `agentmon`: a live dashboard of AI coding agent sessions (Claude, Codex and their desktop apps) on macOS, opening on the dense layout; `--layout classic` and `--layout visual` for the others. Enter drills into a session, Escape comes back.
|
|
10
|
+
- Per session and per process: CPU, real memory (footprint), resident size, disk read/write rates, network in/out and connections (from `nettop`), memory growth and page-ins.
|
|
11
|
+
- Memory: Activity Monitor's breakdown, memory pressure and the sessions driving it.
|
|
12
|
+
- Processes: scopes (Agents, All, Busy, Heavy, Writing, Waiting), grouping, search, a detail drawer, and terminate/kill with confirmation.
|
|
13
|
+
- Commands: `top`, `sessions`, `tree`, `memory`, `footprint`, `record`, `report`, plus `completion` and `--version`. Off a terminal every view prints plain text.
|
|
14
|
+
- History of sessions and memory as JSON lines under `~/.local/state/agentmon`, summarised by `agentmon report`.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Gavin
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# agentmon
|
|
2
|
+
|
|
3
|
+
What AI coding agents (Claude, Codex) cost your Mac: per-session CPU, real memory, disk and network I/O, memory pressure, and a history of runs, in your terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
With Homebrew (brings its own Ruby):
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
brew install satoramoto/tap/agentmon
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or as a gem:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
gem install agentmon
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requirements: macOS; the gem needs Ruby 3.3 or newer. No root needed (other users' processes show only what `ps` gives). The network columns come from `nettop`, which ships with macOS; without it they show as unknown and the rest works.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
agentmon # live dashboard, dense layout
|
|
25
|
+
agentmon --layout visual # gauges, areas and heat
|
|
26
|
+
agentmon --layout classic # the original dashboard: sessions, memory, processes, detail
|
|
27
|
+
agentmon --no-motion # no animation (also AGENTMON_MOTION=0)
|
|
28
|
+
agentmon | cat # one plain frame, no terminal needed
|
|
29
|
+
agentmon top --once # agent processes by CPU, one plain table
|
|
30
|
+
agentmon --help # every command and option
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Commands:
|
|
34
|
+
|
|
35
|
+
| Command | Shows |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `top` | Agent processes by CPU, with footprint and disk I/O (`--once`, `--all`, `--sort`, `--session`, `-n`) |
|
|
38
|
+
| `sessions` | Agent sessions now, with CPU, real memory and lifetime totals (`--all` adds recently ended, `--json`) |
|
|
39
|
+
| `tree [SESSION]` | Process tree of each agent session, with footprint and CPU |
|
|
40
|
+
| `memory` | RAM breakdown, pressure and the sessions driving it (`--json`) |
|
|
41
|
+
| `footprint` | Real memory by session: footprint, share, growth, paging (`--json`) |
|
|
42
|
+
| `record` | Record sessions to the history until ctrl+c (`--interval`, `--prune DAYS`) |
|
|
43
|
+
| `report` | Sessions recorded over a period: peak memory, CPU, disk writes (`--since 2h`, `--json`) |
|
|
44
|
+
| `completion zsh\|bash\|fish` | A shell completion script |
|
|
45
|
+
|
|
46
|
+
Every command takes `--version`, `--color`/`--no-color` and `--trace`. Off a terminal, output is plain text for scripts.
|
|
47
|
+
|
|
48
|
+
## Layouts
|
|
49
|
+
|
|
50
|
+
- **dense** (default): an htop-like strip of machine CPU, memory and I/O, the Sessions table, and all agents together. Enter on a session drills into it: its CPU, memory and network, its processes, connections and a detail drawer.
|
|
51
|
+
- **visual**: the same content as meters, braille area charts and heat colours.
|
|
52
|
+
- **classic**: the first dashboard: Sessions, Memory, Processes and the Detail panel side by side.
|
|
53
|
+
|
|
54
|
+
## Keys
|
|
55
|
+
|
|
56
|
+
| Key | Does |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `enter` | Open the selected session (dense, visual) |
|
|
59
|
+
| `F` | Open the selected process's session |
|
|
60
|
+
| `esc` | Back to all sessions; clears a search |
|
|
61
|
+
| `tab` / `shift-tab` | Next / previous panel |
|
|
62
|
+
| `↑` `↓` `j` `k` | Move the selection |
|
|
63
|
+
| `[` `]` | Previous / next scope: Agents, All, Busy, Heavy, Writing, Waiting |
|
|
64
|
+
| `g` | Group by session, directory, name, or as a tree |
|
|
65
|
+
| `s` / `S` | Next sort column / reverse |
|
|
66
|
+
| `/` | Search: free text, or `footprint>500M`, `cpu>5`, `session~repo` |
|
|
67
|
+
| `z` | Zoom the focused panel |
|
|
68
|
+
| `K` / `X` | Terminate (TERM) / kill (KILL) the selected process or group, after a y/n |
|
|
69
|
+
| `?` | Help: every key and what each panel shows |
|
|
70
|
+
| `q` | Quit |
|
|
71
|
+
|
|
72
|
+
The mouse works too: click focuses a panel and selects a row, the wheel moves the selection.
|
|
73
|
+
|
|
74
|
+
## What the numbers mean
|
|
75
|
+
|
|
76
|
+
- **Session**: a `claude` or `codex` CLI and everything under it (its outermost one), or a desktop app (Claude, ChatGPT/Codex) and its children. Labelled `claude 4242 · repo`.
|
|
77
|
+
- **CPU**: percent of one core, so 250% is two and a half cores. A session's CPU is the sum of its processes.
|
|
78
|
+
- **Footprint** (real memory): the kernel's `phys_footprint`, what Activity Monitor calls Memory. It counts compressed pages, unlike resident size, which is also shown.
|
|
79
|
+
- **Disk read/write**: bytes per second from each process's own I/O counters. Session totals add each process's growth, so very short-lived tools may be missed.
|
|
80
|
+
- **Network in/out**: bytes per second per process and connection, from one `nettop` stream; hosts and connections are counted per session. A session with a sudden spike or many remote hosts is marked `⚑`.
|
|
81
|
+
- **Memory pressure**: `100 − kern.memorystatus_level`, in percent; the Memory panel lists which sessions push it (footprint share and growth per second).
|
|
82
|
+
- **Wait** and **page-ins**: time runnable but waiting for a CPU, and page-ins per second (each one waited for the disk).
|
|
83
|
+
- **Lifetime totals** (CPU seconds, written, peak footprint): counted from when agentmon sees the session; nothing in a process tree is counted twice.
|
|
84
|
+
|
|
85
|
+
Unknown values (an unreadable process, the first sample's rates) are blank, never 0. Sizes are binary (`1.5G` = 1.5 × 1024³ bytes).
|
|
86
|
+
|
|
87
|
+
## History
|
|
88
|
+
|
|
89
|
+
While the dashboard is open, or under `agentmon record`, agentmon writes one JSON line per session (and the memory breakdown) every 10 seconds to `~/.local/state/agentmon/<kind>/YYYY-MM-DD.jsonl` (or `$XDG_STATE_HOME/agentmon`, or `$AGENTMON_STATE_DIR`). `agentmon report` reads it; `agentmon record --prune 30` deletes days older than 30.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
agentmon is built on [r2ui](https://github.com/satoramoto/r2ui). With an r2ui checkout beside this one (`../r2ui`), the Gemfile uses it; otherwise it uses r2ui's `main` on GitHub.
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
bundle install
|
|
97
|
+
bundle exec rake test
|
|
98
|
+
bundle exec exe/agentmon
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See [AGENTS.md](AGENTS.md) for targeted tests, [docs/design.md](docs/design.md) for the architecture and [docs/releasing.md](docs/releasing.md) for releases.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT, see [LICENSE.txt](LICENSE.txt).
|
data/exe/agentmon
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# agentmon the dense layout (one plain frame when piped: `agentmon | cat`)
|
|
5
|
+
# agentmon --layout NAME another layout: classic (the panel dashboard) or a view in docs/views.md
|
|
6
|
+
# agentmon top --once processes of agent sessions, plain table
|
|
7
|
+
# agentmon --help every command
|
|
8
|
+
#
|
|
9
|
+
# macOS only: the library reads processes through libproc (Fiddle) and nettop, so off macOS it
|
|
10
|
+
# stops here with a message instead of failing to load.
|
|
11
|
+
unless RUBY_PLATFORM.include?("darwin")
|
|
12
|
+
warn "agentmon: macOS only (it reads process data from the macOS kernel and nettop); this is #{RUBY_PLATFORM}"
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
require_relative "../lib/agentmon"
|
|
17
|
+
|
|
18
|
+
Agentmon::Program.start(ARGV)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Root-level options every `agentmon` command takes, from r2ui's CLI extensions.
|
|
4
|
+
#
|
|
5
|
+
# agentmon --version agentmon 0.1.0 (also -V, and `agentmon top --version`)
|
|
6
|
+
# agentmon completion zsh a completion script; also bash and fish:
|
|
7
|
+
# eval "$(agentmon completion zsh)" in ~/.zshrc, after compinit
|
|
8
|
+
# eval "$(agentmon completion bash)" in ~/.bashrc
|
|
9
|
+
# agentmon completion fish | source in ~/.config/fish/config.fish
|
|
10
|
+
# agentmon report --trace an unexpected error prints its backtrace (and causes)
|
|
11
|
+
# agentmon top --no-color no colour on a terminal; --color colours a pipe (| less -R)
|
|
12
|
+
#
|
|
13
|
+
# Without --color/--no-color, colour follows the terminal and NO_COLOR/FORCE_COLOR as usual.
|
|
14
|
+
module Agentmon
|
|
15
|
+
cli do
|
|
16
|
+
version Agentmon::VERSION
|
|
17
|
+
completion
|
|
18
|
+
trace_option
|
|
19
|
+
color_option
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# `agentmon footprint`: real memory by agent session now, largest first, with what is growing and
|
|
6
|
+
# what is paging.
|
|
7
|
+
#
|
|
8
|
+
# agentmon footprint a table (boxed on a terminal, plain aligned columns in a pipe)
|
|
9
|
+
# agentmon footprint --json one JSON object per session per line, raw units, for scripts
|
|
10
|
+
#
|
|
11
|
+
# Session Processes Footprint Share Resident Wired Peak Growth Pageins/s
|
|
12
|
+
# claude 200 · repo 4 628M 5.1% 630M 1.0M 628M +1.0M/s 10.0
|
|
13
|
+
# Claude 300 3 501M 4.1% 530M 0B 501M +0B/s 0.0
|
|
14
|
+
#
|
|
15
|
+
# compressed 2.0G · swap 512M (per-process compressed/swap needs root)
|
|
16
|
+
#
|
|
17
|
+
# Footprint is what Activity Monitor calls Memory, summed over the session's processes (each
|
|
18
|
+
# once); Share is its percent of the machine's used memory; Peak the largest footprint seen over
|
|
19
|
+
# the session's life; Growth the footprint change per second over the last minute (a single run
|
|
20
|
+
# sees only the half second between its two samples); Pageins/s counts page-ins (each waited for
|
|
21
|
+
# the disk). Sizes are binary (1M = 1024²), as in the dashboard. The last line is the machine's
|
|
22
|
+
# compressed memory and swap used: macOS splits those out per process only for root. `--json`
|
|
23
|
+
# prints SessionMemory fields (bytes, bytes/s, percent, events/s; null when unknown).
|
|
24
|
+
module Agentmon
|
|
25
|
+
module Commands
|
|
26
|
+
module FootprintList
|
|
27
|
+
HEADERS = ["Session", "Processes", "Footprint", "Share", "Resident", "Wired", "Peak", "Growth",
|
|
28
|
+
"Pageins/s"].freeze
|
|
29
|
+
RIGHT = (1..8).to_h { |i| [i, :right] }.freeze
|
|
30
|
+
EMPTY = "No agent sessions running."
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def cells(m)
|
|
35
|
+
f = R2UI::Format
|
|
36
|
+
[m.label, m.processes.to_s, f.call(:bytes, m.footprint), f.call(:percent, m.share), f.call(:bytes, m.resident),
|
|
37
|
+
f.call(:bytes, m.wired), f.call(:bytes, m.peak_footprint), growth(m.growth_rate),
|
|
38
|
+
f.call(:number, m.pagein_rate)].map(&:to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# "+1.5M/s", "-12K/s"; "" when unknown.
|
|
42
|
+
def growth(rate)
|
|
43
|
+
return "" if rate.nil?
|
|
44
|
+
|
|
45
|
+
"#{rate.negative? ? "-" : "+"}#{R2UI::Format.bytes(rate.abs)}/s"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The machine's compressed and swap, or nil without a MemoryView.
|
|
49
|
+
def machine(memory)
|
|
50
|
+
memory && "compressed #{R2UI::Format.bytes(memory.compressed.to_i)} · swap #{R2UI::Format.bytes(memory.swap_used.to_i)} " \
|
|
51
|
+
"(per-process compressed/swap needs root)"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def json(m) = JSON.generate(m.to_h)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
command :footprint do
|
|
59
|
+
summary "Real memory by agent session now: footprint, share, growth, paging"
|
|
60
|
+
flag :json, desc: "One JSON object per session per line, in raw units"
|
|
61
|
+
|
|
62
|
+
run do
|
|
63
|
+
list = Commands::FootprintList
|
|
64
|
+
reading = Agentmon.engine.current
|
|
65
|
+
rows = reading[:session_memory] || [] # nil when the metric failed this sample (the error goes to stderr)
|
|
66
|
+
if options[:json]
|
|
67
|
+
rows.each { |m| shell.puts(list.json(m)) }
|
|
68
|
+
elsif rows.empty?
|
|
69
|
+
shell.puts(list::EMPTY)
|
|
70
|
+
else
|
|
71
|
+
cells = Commands::SessionList.fit(rows.map { |m| list.cells(m) }, list::HEADERS, width: shell.width,
|
|
72
|
+
boxed: shell.live?)
|
|
73
|
+
table(cells, headers: list::HEADERS, align: list::RIGHT)
|
|
74
|
+
if (line = list.machine(reading[:memory]))
|
|
75
|
+
shell.puts
|
|
76
|
+
shell.puts(line)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# `agentmon memory`: this Mac's RAM right now, the way Activity Monitor breaks it down, then the
|
|
6
|
+
# agent sessions pushing memory pressure hardest.
|
|
7
|
+
#
|
|
8
|
+
# agentmon memory
|
|
9
|
+
#
|
|
10
|
+
# Memory
|
|
11
|
+
# Total 16G
|
|
12
|
+
# Used 12G
|
|
13
|
+
# App 8.0G
|
|
14
|
+
# Wired 2.0G
|
|
15
|
+
# Compressed 2.0G (3.1x)
|
|
16
|
+
# Cached 3.0G
|
|
17
|
+
# Swap 1.0G / 2.0G
|
|
18
|
+
# Swap in 1.0K/s
|
|
19
|
+
# Swap out 0B/s
|
|
20
|
+
# Compression 2.0M/s
|
|
21
|
+
# Decompression 512K/s
|
|
22
|
+
# Pressure 35.0%
|
|
23
|
+
#
|
|
24
|
+
# Pressure drivers
|
|
25
|
+
# Session Footprint Share Growth
|
|
26
|
+
# claude 200 · repo 512M 4.2% +1.0M/s
|
|
27
|
+
#
|
|
28
|
+
# agentmon memory --json one JSON object: {"memory": {...}, "pressure_drivers": [...]} in raw
|
|
29
|
+
# units (bytes, bytes/s, percent) for scripts
|
|
30
|
+
#
|
|
31
|
+
# Used = App + Wired + Compressed; Compressed is the RAM the compressor occupies and (3.1x) how much
|
|
32
|
+
# it holds per byte. Rates are since the previous sample. Pressure is 100 - kern.memorystatus_level.
|
|
33
|
+
# Share is a session's footprint as a percent of used memory; Growth its footprint change per
|
|
34
|
+
# second over the last minute. Sizes are binary (1G = 1024³), as in the dashboard. "—" is unknown.
|
|
35
|
+
module Agentmon
|
|
36
|
+
module Commands
|
|
37
|
+
module MemoryReport
|
|
38
|
+
DRIVERS = 5
|
|
39
|
+
HEADERS = %w[Session Footprint Share Growth].freeze
|
|
40
|
+
RIGHT = { 1 => :right, 2 => :right, 3 => :right }.freeze
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# [[key, value]] for r2ui `pairs`; nil values print as "—".
|
|
45
|
+
def pairs(view)
|
|
46
|
+
size = ->(v) { v && R2UI::Format.bytes(v) }
|
|
47
|
+
rate = ->(v) { v && R2UI::Format.call(:bytes_per_sec, v) }
|
|
48
|
+
compressed = size.(view.compressed)
|
|
49
|
+
compressed = "#{compressed} (#{R2UI::Format.call(:ratio, view.compression_ratio)})" if compressed && view.compression_ratio
|
|
50
|
+
swap = [view.swap_used, view.swap_total].all?(&:nil?) ? nil : "#{size.(view.swap_used) || "—"} / #{size.(view.swap_total) || "—"}"
|
|
51
|
+
[
|
|
52
|
+
["Total", size.(view.total)], ["Used", size.(view.used)], ["App", size.(view.app)],
|
|
53
|
+
["Wired", size.(view.wired)], ["Compressed", compressed], ["Cached", size.(view.cached)], ["Swap", swap],
|
|
54
|
+
["Swap in", rate.(view.swapin_rate)], ["Swap out", rate.(view.swapout_rate)],
|
|
55
|
+
["Compression", rate.(view.compression_rate)], ["Decompression", rate.(view.decompression_rate)],
|
|
56
|
+
["Pressure", view.pressure && R2UI::Format.call(:percent, view.pressure)]
|
|
57
|
+
]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cells(driver)
|
|
61
|
+
f = R2UI::Format
|
|
62
|
+
[driver.label, f.call(:bytes, driver.footprint), f.call(:percent, driver.share), growth(driver.growth_rate)]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# "+1.0M/s", "-2.0K/s", "0B/s" (Format.bytes doesn't take negatives).
|
|
66
|
+
def growth(rate)
|
|
67
|
+
return "" if rate.nil?
|
|
68
|
+
|
|
69
|
+
sign = if rate.positive? then "+" elsif rate.negative? then "-" else "" end
|
|
70
|
+
"#{sign}#{R2UI::Format.call(:bytes_per_sec, rate.abs)}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def json(view, drivers)
|
|
74
|
+
JSON.generate(memory: view.to_h, pressure_drivers: drivers&.map(&:to_h))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
command :memory do
|
|
80
|
+
summary "RAM breakdown, pressure and the sessions driving it"
|
|
81
|
+
flag :json, desc: "Print raw values as one JSON object"
|
|
82
|
+
|
|
83
|
+
run do
|
|
84
|
+
report = Commands::MemoryReport
|
|
85
|
+
reading = Agentmon.engine.current
|
|
86
|
+
view = reading[:memory]
|
|
87
|
+
drivers = reading[:pressure_drivers]
|
|
88
|
+
unless view
|
|
89
|
+
# abort! skips after_run hooks, so say what broke here.
|
|
90
|
+
Agentmon.engine_errors.each { |name, message| shell.err_puts("#{shell.symbol(:warn)} #{name}: #{message}") }
|
|
91
|
+
abort!("Memory isn't available (no memory metric, or it failed this sample)")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if options[:json]
|
|
95
|
+
shell.puts(report.json(view, drivers))
|
|
96
|
+
else
|
|
97
|
+
pairs report.pairs(view), title: "Memory"
|
|
98
|
+
if drivers
|
|
99
|
+
say
|
|
100
|
+
say "Pressure drivers", :heading
|
|
101
|
+
if drivers.empty?
|
|
102
|
+
say "No agent sessions are using memory.", :muted
|
|
103
|
+
else
|
|
104
|
+
table(drivers.first(report::DRIVERS).map { |d| report.cells(d) }, headers: report::HEADERS, align: report::RIGHT)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `agentmon record`: keeps a history of agent sessions without the dashboard open. Samples every
|
|
4
|
+
# `--interval` seconds with recording on, so every recorder (sessions, memory, ...) appends to the
|
|
5
|
+
# store (`$AGENTMON_STATE_DIR`, else `~/.local/state/agentmon`), until ctrl+c or TERM.
|
|
6
|
+
#
|
|
7
|
+
# agentmon record on a terminal: one live status line, redrawn in place
|
|
8
|
+
# agentmon record --interval 5 sample every 5 s instead of 2
|
|
9
|
+
# agentmon record --prune 14 first delete day files older than 14 days
|
|
10
|
+
# agentmon record > record.log off a terminal: one status line a minute
|
|
11
|
+
#
|
|
12
|
+
# recording 3 sessions to /Users/me/.local/state/agentmon
|
|
13
|
+
#
|
|
14
|
+
# TERM (launchd, `kill`) exits 0 and ctrl+c exits 130, both after the current sample: every store
|
|
15
|
+
# line is complete and on disk (each append closes its file). `agentmon report` reads the history.
|
|
16
|
+
module Agentmon
|
|
17
|
+
module Commands
|
|
18
|
+
module Record
|
|
19
|
+
# Seconds between status lines off a terminal.
|
|
20
|
+
STATUS_EVERY = 60
|
|
21
|
+
# Exit code per signal that stops recording.
|
|
22
|
+
SIGNALS = { "TERM" => 0, "INT" => 130 }.freeze
|
|
23
|
+
|
|
24
|
+
# The stop flag the signal handlers raise. `request` only writes to a pipe, which is safe in a
|
|
25
|
+
# trap handler; `wait` sleeps until the timeout or a request, whichever comes first.
|
|
26
|
+
class Stop
|
|
27
|
+
attr_reader :signal
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@reader, @writer = IO.pipe
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request(signal)
|
|
34
|
+
@signal ||= signal
|
|
35
|
+
@writer.write_nonblock(".", exception: false)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def requested? = !@signal.nil?
|
|
39
|
+
|
|
40
|
+
def wait(seconds)
|
|
41
|
+
@reader.wait_readable(seconds) if seconds.positive? && !requested?
|
|
42
|
+
requested?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def close = [@reader, @writer].each(&:close)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# "recording 3 sessions to <dir>"; without the session ledger (nil), just "recording to <dir>".
|
|
51
|
+
def status(reading, store)
|
|
52
|
+
sessions = reading[:session_ledger]
|
|
53
|
+
count = sessions && " #{R2UI::CLI::Ext::HumanFormat.plural(sessions.count(&:alive?), "session")}"
|
|
54
|
+
"recording#{count} to #{store.dir}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Samples every `interval` seconds with recording on until `stop` is requested: a live status
|
|
58
|
+
# line on a terminal, a plain one every STATUS_EVERY seconds off it. Returns the signal.
|
|
59
|
+
def run(engine, shell:, interval:, stop:, clock: Clock, status_every: STATUS_EVERY)
|
|
60
|
+
was = engine.recording
|
|
61
|
+
engine.recording = true
|
|
62
|
+
line = "recording to #{engine.store.dir}"
|
|
63
|
+
live = R2UI::CLI::Live.new(shell, fps: 2) { |frame| "#{R2UI::CLI::Live.spinner(frame)} #{line}" }
|
|
64
|
+
next_status = nil
|
|
65
|
+
live.run do
|
|
66
|
+
until stop.requested?
|
|
67
|
+
started = clock.mono
|
|
68
|
+
line = status(engine.current(max_age: 0), engine.store)
|
|
69
|
+
if live.live?
|
|
70
|
+
live.refresh
|
|
71
|
+
elsif next_status.nil? || started >= next_status
|
|
72
|
+
shell.puts(line)
|
|
73
|
+
next_status = started + status_every
|
|
74
|
+
end
|
|
75
|
+
stop.wait(interval - (clock.mono - started))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
stop.signal
|
|
79
|
+
ensure
|
|
80
|
+
engine.recording = was
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
command :record do
|
|
86
|
+
summary "Record agent sessions to the history until ctrl+c or TERM"
|
|
87
|
+
option :interval, :float, default: 2.0, desc: "Seconds between samples"
|
|
88
|
+
option :prune, :integer, desc: "First delete day files older than this many days"
|
|
89
|
+
|
|
90
|
+
run do
|
|
91
|
+
record = Commands::Record
|
|
92
|
+
usage_error!("--interval must be more than 0") unless options[:interval].positive?
|
|
93
|
+
usage_error!("--prune must be at least 1 day") if options[:prune] && options[:prune] < 1
|
|
94
|
+
engine = Agentmon.engine
|
|
95
|
+
abort!("no store to record to") unless engine.store
|
|
96
|
+
if options[:prune]
|
|
97
|
+
removed = engine.store.prune(days: options[:prune])
|
|
98
|
+
plural = R2UI::CLI::Ext::HumanFormat.method(:plural)
|
|
99
|
+
shell.puts("pruned #{plural.call(removed.size, "day file")} older than #{plural.call(options[:prune], "day")}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
stop = record::Stop.new
|
|
103
|
+
previous = record::SIGNALS.keys.to_h { |sig| [sig, Signal.trap(sig) { stop.request(sig) }] }
|
|
104
|
+
begin
|
|
105
|
+
signal = record.run(engine, shell:, interval: options[:interval], stop:)
|
|
106
|
+
ensure
|
|
107
|
+
previous.each { |sig, handler| Signal.trap(sig, handler || "DEFAULT") }
|
|
108
|
+
stop.close
|
|
109
|
+
end
|
|
110
|
+
code = record::SIGNALS.fetch(signal, 0)
|
|
111
|
+
next if code.zero?
|
|
112
|
+
|
|
113
|
+
# halt skips the after_run hooks, so report what's broken here, as they would.
|
|
114
|
+
engine.errors.each { |name, message| shell.err_puts("#{shell.symbol(:warn)} #{name}: #{message}") }
|
|
115
|
+
halt(code)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# `agentmon report`: what agent sessions cost this Mac over a period, from the recorded history
|
|
6
|
+
# (the `sessions` store lines that the dashboard and `agentmon record` write).
|
|
7
|
+
#
|
|
8
|
+
# agentmon report the last 24 hours
|
|
9
|
+
# agentmon report --since 90m or 2h, 3d
|
|
10
|
+
# agentmon report --json one merged record per line, raw units (bytes, seconds)
|
|
11
|
+
#
|
|
12
|
+
# Agent sessions · last 24h
|
|
13
|
+
#
|
|
14
|
+
# Session Started Duration Peak CPU Written Status
|
|
15
|
+
# claude 4242 · repo 09:14 2h 0m 900M 10m 0s 2.0G ended 11:14
|
|
16
|
+
# claude 4300 · web 13:40 1h 0m 2.0G 1h 0m 100M ended (not seen after 14:02)
|
|
17
|
+
# claude 4410 · repo 15:02 20m 5s 100M 1m 0s 0B running
|
|
18
|
+
#
|
|
19
|
+
# 3 sessions · 1h 11m CPU · 2.1G written
|
|
20
|
+
#
|
|
21
|
+
# Lines of one session (several agentmon runs record it) are merged taking each lifetime total's
|
|
22
|
+
# maximum: every run recounts from the kernel's counters, so a sum would double count. A session
|
|
23
|
+
# with no end whose last line is over a minute old is shown ended when it was last seen, never
|
|
24
|
+
# running: agentmon wasn't running when it ended. Sizes are binary (1G = 1024³), as in the
|
|
25
|
+
# dashboard; written bytes are a lower bound (see docs/design.md, "Lifetime totals").
|
|
26
|
+
module Agentmon
|
|
27
|
+
module Commands
|
|
28
|
+
module Report
|
|
29
|
+
PERIOD = /\A([1-9]\d*)([mhd])\z/
|
|
30
|
+
UNIT = { "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
|
|
31
|
+
# A session with no ended_at is running only if it was seen this recently (6 recorder lines).
|
|
32
|
+
RUNNING_WITHIN = 60
|
|
33
|
+
TOTALS = %i[cpu_seconds bytes_read bytes_written peak_footprint].freeze
|
|
34
|
+
HEADERS = %w[Session Started Duration Peak CPU Written Status].freeze
|
|
35
|
+
RIGHT = { 1 => :right, 2 => :right, 3 => :right, 4 => :right, 5 => :right }.freeze
|
|
36
|
+
|
|
37
|
+
# The --since value type: the text back when it's a period ("90m", "2h", "3d").
|
|
38
|
+
SINCE = lambda do |text|
|
|
39
|
+
raise ArgumentError, "expects a period like 90m, 2h or 3d" unless PERIOD.match?(text)
|
|
40
|
+
|
|
41
|
+
text
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def seconds(period)
|
|
47
|
+
number, unit = PERIOD.match(period).captures
|
|
48
|
+
Integer(number) * UNIT.fetch(unit)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Store lines → one Hash per session id, oldest start first, with `status` ("running",
|
|
52
|
+
# "ended", "not_seen") and `duration` (seconds) added, and the per-line `t` and `run` gone.
|
|
53
|
+
def merge(lines, now:)
|
|
54
|
+
lines.group_by { |l| l[:id] }.map do |_id, group|
|
|
55
|
+
group = group.sort_by { |l| l[:t].to_f }
|
|
56
|
+
merged = group.last.except(:t, :run)
|
|
57
|
+
TOTALS.each { |k| merged[k] = group.filter_map { |l| l[k] }.max }
|
|
58
|
+
merged[:started_at] = group.filter_map { |l| l[:started_at] }.min
|
|
59
|
+
merged[:first_seen_at] = group.filter_map { |l| l[:first_seen_at] }.min
|
|
60
|
+
merged[:last_seen_at] = group.filter_map { |l| l[:last_seen_at] }.max
|
|
61
|
+
merged[:ended_at] = group.filter_map { |l| l[:ended_at] }.max
|
|
62
|
+
finish(merged, now)
|
|
63
|
+
end.sort_by { |s| [(s[:started_at] || s[:first_seen_at]).to_f, s[:id].to_s] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def finish(session, now)
|
|
67
|
+
last = session[:last_seen_at]
|
|
68
|
+
session[:status] = if session[:ended_at] then "ended"
|
|
69
|
+
elsif last && now - last <= RUNNING_WITHIN then "running"
|
|
70
|
+
else "not_seen"
|
|
71
|
+
end
|
|
72
|
+
start = session[:started_at] || session[:first_seen_at]
|
|
73
|
+
stop = session[:ended_at] || last
|
|
74
|
+
session[:duration] = start && stop ? [stop - start, 0.0].max : nil
|
|
75
|
+
session
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# "14:02" today, "Sep 30 14:02" on another day.
|
|
79
|
+
def clock(t, now)
|
|
80
|
+
return "" unless t
|
|
81
|
+
|
|
82
|
+
time = Time.at(t)
|
|
83
|
+
time.strftime("%F") == Time.at(now).strftime("%F") ?time.strftime("%H:%M") : time.strftime("%b %-d %H:%M")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def status(session, now)
|
|
87
|
+
case session[:status]
|
|
88
|
+
when "running" then "running"
|
|
89
|
+
when "ended" then "ended #{clock(session[:ended_at], now)}"
|
|
90
|
+
else "ended (not seen after #{clock(session[:last_seen_at], now)})"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
command :report do
|
|
97
|
+
summary "Agent sessions recorded over a period, with their peak memory, CPU and disk writes"
|
|
98
|
+
option :since, Commands::Report::SINCE, default: "24h", placeholder: "period", desc: "How far back: 90m, 2h, 3d"
|
|
99
|
+
flag :json, desc: "One merged session record per line, raw units"
|
|
100
|
+
|
|
101
|
+
run do
|
|
102
|
+
report = Commands::Report
|
|
103
|
+
now = Clock.now
|
|
104
|
+
store = Agentmon.engine.store || Store.new
|
|
105
|
+
sessions = report.merge(store.each(:sessions, since: now - report.seconds(options[:since])).to_a, now:)
|
|
106
|
+
|
|
107
|
+
if options[:json]
|
|
108
|
+
sessions.each { |s| shell.puts(JSON.generate(s)) }
|
|
109
|
+
elsif sessions.empty?
|
|
110
|
+
say "No agent sessions recorded in the last #{options[:since]}."
|
|
111
|
+
else
|
|
112
|
+
bytes = ->(n) { R2UI::Format.call(:bytes, n) }
|
|
113
|
+
time = ->(s) { s ? duration(s) : "" }
|
|
114
|
+
heading "Agent sessions · last #{options[:since]}"
|
|
115
|
+
rows = sessions.map do |s|
|
|
116
|
+
[s[:label].to_s, report.clock(s[:started_at] || s[:first_seen_at], now), time.call(s[:duration]),
|
|
117
|
+
bytes.call(s[:peak_footprint]), time.call(s[:cpu_seconds]), bytes.call(s[:bytes_written]), report.status(s, now)]
|
|
118
|
+
end
|
|
119
|
+
table rows, headers: report::HEADERS, align: report::RIGHT
|
|
120
|
+
say
|
|
121
|
+
cpu = sessions.sum { |s| s[:cpu_seconds].to_f }
|
|
122
|
+
written = sessions.sum { |s| s[:bytes_written].to_i }
|
|
123
|
+
say "#{plural(sessions.size, "session")} · #{duration(cpu)} CPU · #{bytes.call(written)} written"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|