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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/exe/agentmon +18 -0
  6. data/lib/agentmon/commands/cli_options.rb +21 -0
  7. data/lib/agentmon/commands/footprint.rb +81 -0
  8. data/lib/agentmon/commands/memory.rb +110 -0
  9. data/lib/agentmon/commands/record.rb +118 -0
  10. data/lib/agentmon/commands/report.rb +127 -0
  11. data/lib/agentmon/commands/sessions.rb +98 -0
  12. data/lib/agentmon/commands/top.rb +87 -0
  13. data/lib/agentmon/commands/tree.rb +96 -0
  14. data/lib/agentmon/darwin.rb +235 -0
  15. data/lib/agentmon/engine.rb +115 -0
  16. data/lib/agentmon/focus.rb +88 -0
  17. data/lib/agentmon/metrics/memory.rb +76 -0
  18. data/lib/agentmon/metrics/network.rb +245 -0
  19. data/lib/agentmon/metrics/pressure_drivers.rb +57 -0
  20. data/lib/agentmon/metrics/process_rates.rb +65 -0
  21. data/lib/agentmon/metrics/process_rows.rb +41 -0
  22. data/lib/agentmon/metrics/session_ledger.rb +160 -0
  23. data/lib/agentmon/metrics/session_memory.rb +84 -0
  24. data/lib/agentmon/metrics/session_names.rb +169 -0
  25. data/lib/agentmon/metrics/sessions.rb +80 -0
  26. data/lib/agentmon/metrics/system.rb +114 -0
  27. data/lib/agentmon/model.rb +263 -0
  28. data/lib/agentmon/probes/cwd.rb +32 -0
  29. data/lib/agentmon/probes/memory.rb +139 -0
  30. data/lib/agentmon/probes/network.rb +326 -0
  31. data/lib/agentmon/probes/processes.rb +72 -0
  32. data/lib/agentmon/probes/system.rb +134 -0
  33. data/lib/agentmon/program.rb +55 -0
  34. data/lib/agentmon/reading.rb +83 -0
  35. data/lib/agentmon/recorders/memory.rb +19 -0
  36. data/lib/agentmon/recorders/sessions.rb +40 -0
  37. data/lib/agentmon/registry.rb +155 -0
  38. data/lib/agentmon/sampler.rb +50 -0
  39. data/lib/agentmon/store.rb +86 -0
  40. data/lib/agentmon/ui/connections.rb +75 -0
  41. data/lib/agentmon/ui/interaction.rb +94 -0
  42. data/lib/agentmon/ui/memory.rb +125 -0
  43. data/lib/agentmon/ui/process_actions.rb +49 -0
  44. data/lib/agentmon/ui/process_detail.rb +148 -0
  45. data/lib/agentmon/ui/process_network.rb +13 -0
  46. data/lib/agentmon/ui/process_scopes.rb +32 -0
  47. data/lib/agentmon/ui/process_waits.rb +99 -0
  48. data/lib/agentmon/ui/processes.rb +42 -0
  49. data/lib/agentmon/ui/session_focus.rb +126 -0
  50. data/lib/agentmon/ui/session_memory.rb +78 -0
  51. data/lib/agentmon/ui/sessions.rb +130 -0
  52. data/lib/agentmon/ui/theme.rb +49 -0
  53. data/lib/agentmon/ui.rb +80 -0
  54. data/lib/agentmon/version.rb +5 -0
  55. data/lib/agentmon/views/dense.rb +157 -0
  56. data/lib/agentmon/views/history.rb +62 -0
  57. data/lib/agentmon/views/signals.rb +279 -0
  58. data/lib/agentmon/views/visual.rb +172 -0
  59. data/lib/agentmon/views/widgets.rb +352 -0
  60. data/lib/agentmon/views.rb +875 -0
  61. data/lib/agentmon.rb +38 -0
  62. 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