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
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pty"
4
+
5
+ module Agentmon
6
+ # sample[:network]: every process's sockets and byte counters, as a NetSnapshot (model.rb), from
7
+ # ONE long-lived `nettop` child. Nil until its first block arrives, when the latest block is older
8
+ # than STALE seconds (nettop stalled), and off macOS.
9
+ #
10
+ # nettop in connection mode (`-x -L 0 -s 1 -J interface,state,bytes_in,bytes_out`, no -P) prints
11
+ # one block a second: a header line `,interface,state,bytes_in,bytes_out,`, then per process a
12
+ # line `Name With Spaces.PID,,,BYTES_IN,BYTES_OUT,` followed by its flows:
13
+ #
14
+ # tcp4 192.0.2.5:55584<->198.51.100.11:5223,en0,Established,3177530,7189392,
15
+ # udp4 *:5353<->*:*,en0,,4823549,2970949,
16
+ # tcp6 fe80::1%lo0.53<->*.*,lo0,Listen,,,
17
+ #
18
+ # IPv6 endpoints put `.` before the port, IPv4 (and host names) `:`; `*` is a wildcard; empty byte
19
+ # fields are unknown (nil). Byte counts are cumulative. A one-shot `nettop -L 1` takes ~5 s, so it
20
+ # is never run per sample or per process.
21
+ #
22
+ # Piped, nettop block-buffers its output (nothing for many seconds), so the child runs under a pty
23
+ # (PTY.spawn), where each block arrives at once. A reader thread parses lines and publishes each
24
+ # complete block, frozen, as soon as the next header arrives or the pty has been idle for IDLE
25
+ # seconds. `at_mono` is when the block's header arrived, so rates come from the snapshots' own
26
+ # clock whatever the engine's interval. The child starts on the first probe call, restarts at most
27
+ # once every RESTART_EVERY seconds when it dies (MISSING_RETRY when nettop is missing), and is
28
+ # stopped (TERM, KILL after KILL_AFTER, then reaped) by an at_exit hook or Stream#stop.
29
+ module Probes
30
+ module Network
31
+ COMMAND = %w[/usr/bin/nettop -x -L 0 -s 1 -J interface,state,bytes_in,bytes_out].freeze
32
+
33
+ module_function
34
+
35
+ # Every block in captured nettop text, as NetSnapshots (the last one even when its block is
36
+ # cut short). Block i gets at_mono `at_mono + i * every`. Pure; tests feed it fixtures.
37
+ def parse(text, at_mono: 0.0, every: 1.0)
38
+ parser = Parser.new
39
+ blocks = -1
40
+ snapshots = text.each_line.filter_map do |line|
41
+ blocks += 1 if Parser.header?(line)
42
+ parser.feed(line, at_mono + (blocks * every))
43
+ end
44
+ last = parser.flush
45
+ last ? snapshots << last : snapshots
46
+ end
47
+
48
+ # The process's one stream.
49
+ def stream = @stream ||= Stream.new
50
+
51
+ # The probe: the latest snapshot, starting nettop when needed. Never raises into the sampler.
52
+ def read
53
+ Darwin.available? ? stream.snapshot : nil
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ # Turns nettop lines into NetSnapshots, one block at a time. Not thread-safe: one feeder.
59
+ class Parser
60
+ HEADER = /\A,interface,/
61
+
62
+ def self.header?(line) = HEADER.match?(line)
63
+
64
+ def initialize
65
+ @block = nil # { at_mono:, processes: { pid => builder Hash } }
66
+ @current = nil # the process the next flow lines belong to
67
+ @dirty = false # changed since the last snapshot handed out
68
+ end
69
+
70
+ # Feeds one line; returns the previous block's snapshot when this line is the header that
71
+ # closes it (and it changed since it was last handed out), else nil. Lines before the first
72
+ # header (a block we joined midway) are dropped.
73
+ def feed(line, at_mono)
74
+ line = line.delete("\r\n")
75
+ return nil if line.empty?
76
+
77
+ if self.class.header?(line)
78
+ done = flush
79
+ @block = { at_mono:, processes: {} }
80
+ @current = nil
81
+ @dirty = true
82
+ return done
83
+ end
84
+ return nil unless @block
85
+
86
+ add(line)
87
+ nil
88
+ end
89
+
90
+ # The block so far as a snapshot, if it changed since the last one handed out; else nil.
91
+ def flush
92
+ return nil unless @block && @dirty
93
+
94
+ @dirty = false
95
+ snapshot
96
+ end
97
+
98
+ private
99
+
100
+ def add(line)
101
+ head, interface, state, bytes_in, bytes_out = fields(line)
102
+ return unless head
103
+
104
+ if head.include?("<->")
105
+ return unless @current
106
+
107
+ @current[:flows] << flow(head, interface, state, bytes_in, bytes_out)
108
+ else
109
+ name, dot, pid = head.rpartition(".")
110
+ pid = Integer(pid, 10, exception: false)
111
+ return if dot.empty? || pid.nil?
112
+
113
+ @current = @block[:processes][pid] = { pid:, name:, bytes_in: count(bytes_in),
114
+ bytes_out: count(bytes_out), flows: [] }
115
+ end
116
+ @dirty = true
117
+ end
118
+
119
+ # [first field, interface, state, bytes_in, bytes_out]; the first field may hold commas.
120
+ def fields(line)
121
+ parts = line.split(",", -1)
122
+ parts.pop if line.end_with?(",")
123
+ return nil if parts.size < 5
124
+
125
+ [parts[0...-4].join(","), *parts[-4..]]
126
+ end
127
+
128
+ def flow(head, interface, state, bytes_in, bytes_out)
129
+ protocol, endpoints = head.split(" ", 2)
130
+ local, remote = endpoints.to_s.split("<->", 2)
131
+ host, port = Network.endpoint(remote, protocol)
132
+ NetFlow.new(protocol:, local:, remote:, remote_host: host, remote_port: port,
133
+ interface: blank(interface), state: blank(state), bytes_in: count(bytes_in),
134
+ bytes_out: count(bytes_out))
135
+ end
136
+
137
+ def snapshot
138
+ processes = @block[:processes].transform_values do |p|
139
+ NetProcess.new(**p.except(:flows), flows: p[:flows].dup.freeze)
140
+ end
141
+ NetSnapshot.new(at_mono: @block[:at_mono], processes: processes.freeze)
142
+ end
143
+
144
+ def count(text) = text.nil? || text.empty? ? nil : Integer(text, 10, exception: false)
145
+
146
+ def blank(text) = text.nil? || text.empty? ? nil : text
147
+ end
148
+
149
+ # [host, port] of an endpoint as nettop prints it: IPv6 (`protocol` ending in 6) separates the
150
+ # port with the last `.`, IPv4 and names with `:`. `*` (either part) is nil.
151
+ def endpoint(text, protocol)
152
+ return [nil, nil] if text.nil? || text.empty?
153
+
154
+ separators = protocol.to_s.end_with?("6") ? [".", ":"] : [":", "."]
155
+ host, port = nil
156
+ separators.each do |sep|
157
+ h, s, p = text.rpartition(sep)
158
+ next if s.empty?
159
+
160
+ host = h
161
+ port = p
162
+ break
163
+ end
164
+ host ||= text
165
+ host = nil if host == "*"
166
+ port = port.nil? || port == "*" ? nil : Integer(port, 10, exception: false)
167
+ [host, port]
168
+ end
169
+
170
+ # The nettop child and its reader thread. `feed`/`idle` are the reader's entry points, public
171
+ # so tests drive publishing without spawning.
172
+ class Stream
173
+ IDLE = 0.05 # seconds of pty silence that end a block
174
+ STALE = 5.0 # a snapshot older than this is not returned
175
+ RESTART_EVERY = 10.0 # seconds between starts when the child keeps dying
176
+ MISSING_RETRY = 60.0 # seconds between tries when nettop is missing
177
+ KILL_AFTER = 1.0 # seconds between TERM and KILL on stop
178
+
179
+ attr_reader :pid
180
+
181
+ def initialize(command: COMMAND, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
182
+ @command = command
183
+ @clock = clock
184
+ @parser = Parser.new
185
+ @latest = nil
186
+ @next_start = nil
187
+ @lock = Mutex.new
188
+ end
189
+
190
+ # The probe's call: starts (or restarts) nettop when due, returns the latest fresh snapshot.
191
+ def snapshot
192
+ @lock.synchronize { ensure_running }
193
+ latest
194
+ end
195
+
196
+ # The latest published snapshot, or nil when none yet or it is older than STALE.
197
+ def latest
198
+ snap = @latest
199
+ snap if snap && @clock.call - snap.at_mono <= STALE
200
+ end
201
+
202
+ # One line from nettop (the reader thread, or a test).
203
+ def feed(line) = publish(@parser.feed(line, @clock.call))
204
+
205
+ # The pty went quiet: the block so far is complete.
206
+ def idle = publish(@parser.flush)
207
+
208
+ def running? = !@pid.nil? && !reaped?(@pid)
209
+
210
+ def start
211
+ now = @clock.call
212
+ unless File.executable?(@command.first)
213
+ @next_start = now + MISSING_RETRY
214
+ return false
215
+ end
216
+
217
+ @next_start = now + RESTART_EVERY
218
+ @parser = Parser.new
219
+ reader, writer, @pid = PTY.spawn(*@command)
220
+ writer.close
221
+ @reader = reader
222
+ @thread = Thread.new { read_loop(reader) }
223
+ @thread.report_on_exception = false
224
+ register_exit_hook
225
+ true
226
+ rescue SystemCallError
227
+ @next_start = now + MISSING_RETRY
228
+ false
229
+ end
230
+
231
+ # Stops the child (TERM, then KILL after KILL_AFTER) and reaps it; safe to call twice.
232
+ def stop
233
+ @lock.synchronize do
234
+ pid = @pid
235
+ @pid = nil
236
+ terminate(pid) if pid
237
+ begin
238
+ @reader&.close
239
+ rescue IOError
240
+ nil
241
+ end
242
+ @thread&.join(1)
243
+ @thread = nil
244
+ @reader = nil
245
+ @next_start = nil
246
+ end
247
+ end
248
+
249
+ private
250
+
251
+ def ensure_running
252
+ return if running?
253
+ return if @next_start && @clock.call < @next_start
254
+
255
+ start
256
+ end
257
+
258
+ def publish(snap)
259
+ @latest = snap if snap
260
+ snap
261
+ end
262
+
263
+ def read_loop(io)
264
+ buffer = String.new(encoding: Encoding::BINARY)
265
+ loop do
266
+ unless io.wait_readable(IDLE)
267
+ idle
268
+ next
269
+ end
270
+ chunk = io.read_nonblock(65_536, exception: false)
271
+ next if chunk == :wait_readable
272
+ break if chunk.nil?
273
+
274
+ buffer << chunk
275
+ while (i = buffer.index("\n"))
276
+ feed(buffer.slice!(0..i).force_encoding(Encoding::UTF_8).scrub)
277
+ end
278
+ end
279
+ rescue IOError, SystemCallError # Errno::EIO when the child exits; IOError when stop closes it
280
+ nil
281
+ ensure
282
+ idle
283
+ end
284
+
285
+ # True when `pid` has exited (and is now reaped).
286
+ def reaped?(pid)
287
+ !Process.waitpid(pid, Process::WNOHANG).nil?
288
+ rescue Errno::ECHILD
289
+ true
290
+ end
291
+
292
+ def terminate(pid)
293
+ signal(:TERM, pid)
294
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_AFTER
295
+ until reaped?(pid)
296
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
297
+ signal(:KILL, pid)
298
+ begin
299
+ Process.waitpid(pid)
300
+ rescue Errno::ECHILD
301
+ nil
302
+ end
303
+ break
304
+ end
305
+ sleep 0.01
306
+ end
307
+ end
308
+
309
+ def signal(name, pid)
310
+ Process.kill(name, pid)
311
+ rescue Errno::ESRCH, Errno::EPERM
312
+ nil
313
+ end
314
+
315
+ def register_exit_hook
316
+ return if @exit_hook
317
+
318
+ @exit_hook = true
319
+ at_exit { stop }
320
+ end
321
+ end
322
+ end
323
+ end
324
+
325
+ probe(:network) { Probes::Network.read }
326
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # sample[:processes]: every process, as ProcessStat (lib/agentmon/model.rb).
4
+ #
5
+ # One `ps` for the whole table (pid, ppid, rss, %cpu, executable path: what rusage lacks), then
6
+ # proc_pid_rusage and proc_pidinfo(PROC_PIDTASKALLINFO) through Fiddle for each pid: no
7
+ # subprocess per process. ~800 processes take ~50 ms on an M-series Mac: ~12 ms of Fiddle calls
8
+ # and decoding, the rest ps.
9
+ #
10
+ # Processes the kernel won't describe (other users', EPERM) and ones that exit between ps and
11
+ # rusage (ESRCH) keep their ps values: `readable: false`, resident from ps rss, ps %cpu, and nil
12
+ # footprint, CPU time, disk I/O, faults and scheduling counters.
13
+ module Agentmon
14
+ module Probes
15
+ module Processes
16
+ PS = %w[ps -axo pid=,ppid=,rss=,pcpu=,comm=].freeze
17
+
18
+ module_function
19
+
20
+ # `ps_text` and `reader` (anything with Darwin's `rusage(pid)`, `task_info(pid)` and
21
+ # `started_at(pid)`) are injectable for tests.
22
+ #
23
+ # Two calls per readable process: rusage, then task_info (start time plus fault and
24
+ # scheduling counters, one proc_pidinfo). Only if task_info fails where rusage worked (not
25
+ # seen on macOS 26) does a third call fetch the start time alone.
26
+ def read(ps_text: IO.popen(PS, err: File::NULL, &:read), reader: Darwin)
27
+ parse_ps(ps_text).map do |row|
28
+ usage = reader.rusage(row[:pid])
29
+ task = usage && reader.task_info(row[:pid])
30
+ build(row, usage, task ? task.started_at : (usage && reader.started_at(row[:pid])), task)
31
+ end
32
+ end
33
+
34
+ # ps lines -> Hashes. comm is last and may contain spaces.
35
+ def parse_ps(text)
36
+ text.each_line.filter_map do |line|
37
+ pid, ppid, rss, pcpu, path = line.strip.split(nil, 5)
38
+ next unless path
39
+
40
+ { pid: pid.to_i, ppid: ppid.to_i, rss: rss.to_i * 1024, pcpu: pcpu.to_f, path: }
41
+ end
42
+ end
43
+
44
+ def build(row, usage, started_at, task = nil)
45
+ ProcessStat.new(
46
+ pid: row[:pid], ppid: row[:ppid], name: File.basename(row[:path]), path: row[:path],
47
+ readable: !usage.nil?,
48
+ start_ticks: usage&.start_ticks,
49
+ started_at:,
50
+ cpu_time: usage&.cpu_time,
51
+ child_cpu_time: usage&.child_cpu_time,
52
+ resident: usage ? usage.resident : row[:rss],
53
+ footprint: usage&.footprint,
54
+ peak_footprint: usage&.peak_footprint,
55
+ disk_read: usage&.disk_read,
56
+ disk_written: usage&.disk_written,
57
+ ps_cpu: row[:pcpu],
58
+ wired: usage&.wired,
59
+ pageins: usage&.pageins,
60
+ runnable_time: usage&.runnable_time,
61
+ faults: task&.faults,
62
+ cow_faults: task&.cow_faults,
63
+ context_switches: task&.context_switches,
64
+ threads: task&.threads,
65
+ running_threads: task&.running_threads
66
+ )
67
+ end
68
+ end
69
+ end
70
+
71
+ probe(:processes) { Probes::Processes.read }
72
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Agentmon
6
+ # sample[:system]: machine-wide CPU, load and network counters, read once per sample.
7
+ #
8
+ # cpu_ticks [user, system, idle, nice]: cumulative ticks over all CPUs since boot
9
+ # (host_statistics(HOST_CPU_LOAD_INFO), Integers; only deltas mean anything)
10
+ # ncpu logical CPUs (hw.logicalcpu, else hw.ncpu)
11
+ # load [1, 5, 15]-minute load averages (getloadavg, Floats)
12
+ # net_in cumulative bytes received over every interface except lo0 (netstat -ibn)
13
+ # net_out cumulative bytes sent, likewise
14
+ # at_mono monotonic clock when read (seconds), for rates
15
+ #
16
+ # One `netstat -ibn` per sample (no per-interface work). It prints one `<Link#N>` row per
17
+ # interface plus one row per address with the same counters; only the Link rows are summed, so an
18
+ # interface with three addresses counts once. CPU, load and ncpu come through Fiddle.
19
+ #
20
+ # Each part is read on its own: one that fails (netstat missing, a sysctl refused) leaves its
21
+ # fields nil and the others still arrive. Off macOS the probe is nil.
22
+ SystemStat = Data.define(:cpu_ticks, :ncpu, :load, :net_in, :net_out, :at_mono)
23
+
24
+ module Probes
25
+ module System
26
+ HOST_CPU_LOAD_INFO = 3
27
+ CPU_STATES = 4 # CPU_STATE_USER, CPU_STATE_SYSTEM, CPU_STATE_IDLE, CPU_STATE_NICE
28
+ NETSTAT = %w[netstat -ibn].freeze
29
+
30
+ module_function
31
+
32
+ # Reads the live machine.
33
+ def read
34
+ net_in, net_out = attempt { parse_netstat(netstat) }
35
+ SystemStat.new(cpu_ticks: attempt { Native.cpu_ticks }, ncpu: attempt { Native.ncpu },
36
+ load: attempt { Native.load_average }, net_in:, net_out:,
37
+ at_mono: Process.clock_gettime(Process::CLOCK_MONOTONIC))
38
+ end
39
+
40
+ # [bytes in, bytes out] summed over the `<Link#N>` rows of `netstat -ibn` output, lo0 left
41
+ # out; nil when there is no Link row (not netstat's table). Pure; tests feed it captured text.
42
+ #
43
+ # Columns: Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll. Address is
44
+ # empty for some interfaces (utun, gif), so the counters are taken from the right.
45
+ def parse_netstat(text)
46
+ found = false
47
+ totals = text.each_line.each_with_object([0, 0]) do |line, sums|
48
+ fields = line.split
49
+ next unless fields.size >= 10 && fields[2]&.start_with?("<Link#")
50
+ next if fields[0].delete_suffix("*") == "lo0"
51
+
52
+ ibytes = Integer(fields[-5], exception: false)
53
+ obytes = Integer(fields[-2], exception: false)
54
+ next unless ibytes && obytes
55
+
56
+ found = true
57
+ sums[0] += ibytes
58
+ sums[1] += obytes
59
+ end
60
+ found ? totals : nil
61
+ end
62
+
63
+ def netstat = IO.popen(NETSTAT, err: File::NULL, &:read)
64
+
65
+ # The block's value, or nil when it raises (a part that can't be read stays unknown).
66
+ def attempt
67
+ yield
68
+ rescue StandardError # Fiddle::DLError, Errno::ENOENT (no netstat), Agentmon::Error
69
+ nil
70
+ end
71
+
72
+ # The Fiddle calls. Buffers are per call, so reads from several threads don't share memory.
73
+ module Native
74
+ module_function
75
+
76
+ # host_cpu_load_info: natural_t cpu_ticks[CPU_STATE_MAX], in user/system/idle/nice order.
77
+ def cpu_ticks
78
+ buffer = Fiddle::Pointer.malloc(4 * CPU_STATES, Fiddle::RUBY_FREE)
79
+ count = Fiddle::Pointer.malloc(4, Fiddle::RUBY_FREE)
80
+ count[0, 4] = [CPU_STATES].pack("L")
81
+ result = functions[:host_statistics].call(host, HOST_CPU_LOAD_INFO, buffer, count)
82
+ raise Error, "host_statistics failed (kern_return_t #{result})" unless result.zero?
83
+
84
+ buffer[0, 4 * CPU_STATES].unpack("L<#{CPU_STATES}")
85
+ end
86
+
87
+ def load_average
88
+ buffer = Fiddle::Pointer.malloc(8 * 3, Fiddle::RUBY_FREE)
89
+ n = functions[:getloadavg].call(buffer, 3)
90
+ raise Error, "getloadavg failed" unless n == 3
91
+
92
+ buffer[0, 24].unpack("d3")
93
+ end
94
+
95
+ # Logical CPUs: constant for the process's life, so read once.
96
+ def ncpu = @ncpu ||= sysctl_int("hw.logicalcpu") || sysctl_int("hw.ncpu")
97
+
98
+ # An integer sysctl of 4 or 8 bytes, or nil.
99
+ def sysctl_int(name)
100
+ buffer = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
101
+ length = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
102
+ length[0, 8] = [8].pack("Q")
103
+ return nil unless functions[:sysctlbyname].call(name, buffer, length, nil, 0).zero?
104
+
105
+ case length[0, 8].unpack1("Q")
106
+ when 4 then buffer[0, 4].unpack1("l<")
107
+ when 8 then buffer[0, 8].unpack1("q<")
108
+ end
109
+ end
110
+
111
+ # mach_host_self() returns a send right each call; take it once.
112
+ def host = @host ||= functions[:mach_host_self].call
113
+
114
+ def lib = @lib ||= Fiddle.dlopen(nil)
115
+
116
+ def functions
117
+ @functions ||= {
118
+ mach_host_self: Fiddle::Function.new(lib["mach_host_self"], [], -Fiddle::TYPE_INT),
119
+ host_statistics: Fiddle::Function.new(lib["host_statistics"],
120
+ [-Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
121
+ Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT),
122
+ getloadavg: Fiddle::Function.new(lib["getloadavg"], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
123
+ Fiddle::TYPE_INT),
124
+ sysctlbyname: Fiddle::Function.new(lib["sysctlbyname"],
125
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
126
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_INT)
127
+ }
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ probe(:system) { Darwin.available? ? Probes::System.read : nil }
134
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentmon
4
+ # The `agentmon` command line, an r2ui CLI program: the root (no command) opens the dashboard
5
+ # in a layout (`--layout`, default dense), and every registered command is a subcommand.
6
+ module Program
7
+ # The panel dashboard (rows and panels from Agentmon.row/panel), i.e. UI.install(view: nil).
8
+ CLASSIC = "classic"
9
+ DEFAULT_LAYOUT = "dense"
10
+
11
+ module_function
12
+
13
+ # The --layout choices: classic plus the registered views.
14
+ def layouts(registry = Agentmon.registry) = [CLASSIC, *UI.layouts(registry).map(&:to_s)]
15
+
16
+ # The view UI.install takes for a --layout choice (nil: the classic dashboard).
17
+ def view(layout) = layout == CLASSIC ? nil : layout&.to_sym
18
+
19
+ def build(registry: Agentmon.registry)
20
+ layouts = Program.layouts(registry)
21
+ default = layouts.include?(DEFAULT_LAYOUT) ? DEFAULT_LAYOUT : CLASSIC
22
+ R2UI.cli("agentmon") do
23
+ summary "What AI coding agents (Claude, Codex) cost this Mac"
24
+ description <<~TEXT.chomp
25
+ With no command, opens the dashboard in the #{default} layout: agent sessions with their
26
+ CPU, real memory (footprint), disk and network, and memory pressure. --layout #{CLASSIC} is
27
+ the panel dashboard. Off a terminal it prints one frame.
28
+ TEXT
29
+ registry.cli_blocks.each { |b| instance_exec(&b) }
30
+ registry.commands.each { |c| command(c.name, &c.block) }
31
+
32
+ option :layout, in: layouts, default:, desc: "Dashboard layout: #{CLASSIC} or a view (docs/views.md)"
33
+ flag :motion, default: true, desc: "Animate meters, changed values and moved rows (AGENTMON_MOTION=0: off)"
34
+
35
+ run do
36
+ engine = Agentmon.engine
37
+ engine.recording = shell.interactive? # history is written while the dashboard is open
38
+ Views.motion = options[:motion] && ENV["AGENTMON_MOTION"] != "0"
39
+ UI.install(engine:, view: Program.view(options[:layout]))
40
+ dashboard UI::DASHBOARD
41
+ end
42
+ end
43
+ end
44
+
45
+ def start(argv = ARGV) = build.start(argv)
46
+ end
47
+
48
+ # After any command, a broken probe, metric or recorder is reported on stderr ("⚠ metric
49
+ # memory: ..."), so a table with missing columns says why. stdout stays clean for scripts.
50
+ R2UI::CLI.extension :agentmon_errors do
51
+ after_run do
52
+ Agentmon.engine_errors.each { |name, message| shell.err_puts("#{shell.symbol(:warn)} #{name}: #{message}") }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentmon
4
+ # One sample plus everything derived from it: `reading[:process_rows]`, `reading[:sessions]`.
5
+ # A metric's value is computed once, the first time it is asked for (metrics may ask for each
6
+ # other); the Engine asks for all of them before it publishes the reading, so readers on other
7
+ # threads only look values up.
8
+ #
9
+ # Stories land in any order, so a missing or broken metric never stops the others:
10
+ # `reading[:name]` is nil for a metric nobody registered (its story hasn't merged yet), and a
11
+ # metric that raises is nil too, with its error in `reading.errors[:name]` (the dashboard's
12
+ # status bar and the CLI's stderr show them). Consumers treat nil as "not available".
13
+ #
14
+ # Tests build one directly, with fixture samples and preset `values:` for metrics a story
15
+ # consumes but doesn't own:
16
+ #
17
+ # Reading.new(sample, previous: earlier, values: { memory: MemoryView.new(...) })
18
+ class Reading
19
+ attr_reader :sample, :previous, :errors
20
+
21
+ def initialize(sample, previous: nil, metrics: Agentmon.registry.metrics, states: nil, values: {})
22
+ @sample = sample
23
+ @previous = previous
24
+ @metrics = metrics.to_h { |m| [m.name, m] }
25
+ @states = states || Hash.new { |h, k| h[k] = {} }
26
+ @values = values.transform_keys(&:to_sym)
27
+ @errors = {}
28
+ @computing = []
29
+ end
30
+
31
+ # Wall time of the sample (epoch seconds).
32
+ def at = sample.at
33
+
34
+ # Seconds between the previous sample and this one (monotonic), nil for the first.
35
+ def interval = previous && (sample.mono - previous.mono)
36
+
37
+ def [](name)
38
+ name = name.to_sym
39
+ return @values[name] if @values.key?(name)
40
+
41
+ metric = @metrics[name] or return nil
42
+ raise Error, "metric cycle: #{[*@computing, name].join(" -> ")}" if @computing.include?(name)
43
+
44
+ @values[name] = compute(metric)
45
+ end
46
+
47
+ # The focused session id: always nil here; a FocusedReading (lib/agentmon/focus.rb) answers
48
+ # its session. Code that may get either calls these instead of checking the class.
49
+ def focus = nil
50
+
51
+ # The whole machine's reading (a FocusedReading returns the one it narrows).
52
+ def unfocused = self
53
+
54
+ # True when a metric of that name is registered (or preset).
55
+ def key?(name) = @values.key?(name.to_sym) || @metrics.key?(name.to_sym)
56
+
57
+ # Computes every metric (the Engine calls this once per sample).
58
+ def evaluate_all
59
+ @metrics.each_key { |name| self[name] }
60
+ self
61
+ end
62
+
63
+ private
64
+
65
+ def compute(metric)
66
+ @computing.push(metric.name)
67
+ metric.block.call(self, @states[metric.name])
68
+ rescue Error => e
69
+ raise if e.message.start_with?("metric cycle") # a design bug: fail loudly
70
+
71
+ fail_metric(metric, e)
72
+ rescue StandardError => e
73
+ fail_metric(metric, e)
74
+ ensure
75
+ @computing.pop
76
+ end
77
+
78
+ def fail_metric(metric, error)
79
+ @errors[metric.name] = "#{error.class}: #{error.message}"
80
+ nil
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Memory history: while agentmon records (the interactive dashboard, `agentmon record`), one line
4
+ # every 10 s in the store's `memory` kind, e.g. ~/.local/state/agentmon/memory/2026-10-01.jsonl:
5
+ #
6
+ # {"t":1790000010.0,"total":17179869184,"used":11811160064,"app":7516192768,"wired":2147483648,
7
+ # "compressed":2147483648,"cached":3221225472,"free":536870912,"swap_used":1073741824,
8
+ # "swap_total":2147483648,"compression_ratio":3.1,"swapin_rate":0.0,"swapout_rate":4096.0,
9
+ # "compression_rate":1572864.0,"decompression_rate":262144.0,"pressure":38.0,"run":"4242-1790000000"}
10
+ #
11
+ # The line is `reading[:memory]` (a MemoryView: sizes in bytes, rates in bytes/s, pressure in
12
+ # percent) without `pressure_trend` (the line's own history is the trend), stamped with the
13
+ # sample's time `t` and the engine's `run`. Nothing is written while memory is unavailable.
14
+ module Agentmon
15
+ recorder(:memory, every: 10) do |reading, _state|
16
+ view = reading[:memory]
17
+ view && { t: reading.at, **view.to_h.except(:pressure_trend) }
18
+ end
19
+ end