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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ # The Detail panel (beside Processes, a quarter of the main row): everything about the process
6
+ # selected in the Processes table, whichever panel has focus.
7
+ #
8
+ # claude pid 4242
9
+ # Command claude --resume --model opus
10
+ # Directory ~/src/repo
11
+ # Session claude 4242 · repo
12
+ # Footprint 512M
13
+ # Resident 600M
14
+ # Peak 700M
15
+ # CPU time 12m 3s
16
+ # Written 1.2G
17
+ # Started 2h 5m ago
18
+ # Open files 42
19
+ #
20
+ # The full command line comes from one `ps -o args= -p PID` when the selection changes (cached
21
+ # while it stays). The open file count is `proc_pidinfo(PROC_PIDLISTFDS)` through Fiddle. Root's
22
+ # processes can't be read without root: their footprint, CPU time, disk, start and open files say
23
+ # "not readable without root"; resident size is ps's. A group line (g) shows its process count.
24
+ #
25
+ # Other stories add lines below these with `Agentmon.detail_section` (lib/agentmon/registry.rb)
26
+ # instead of editing this file.
27
+ module Agentmon
28
+ module UI
29
+ module ProcessDetail
30
+ PROC_PIDLISTFDS = 1
31
+ FDINFO_SIZE = 8 # struct proc_fdinfo: int32 fd + uint32 type
32
+ UNREADABLE = "not readable without root"
33
+ LABEL_WIDTH = 11
34
+ COMMAND_LINES = 3
35
+
36
+ class << self
37
+ # Injection points for tests: pid -> command line String (nil if gone), pid -> Integer or nil.
38
+ attr_writer :command_reader, :file_counter
39
+
40
+ def command_reader = @command_reader ||= method(:read_command)
41
+ def file_counter = @file_counter ||= method(:open_files)
42
+
43
+ # The panel's text for the app's current selection in the :process panel.
44
+ def render(app, engine, width)
45
+ panel = app.dashboard.panels.find { |p| p.name == :process }
46
+ state = panel && app.panel_state(panel)
47
+ line = state && app.panel_lines(panel)[state.selected]
48
+ return "No process selected" unless line
49
+
50
+ row = line.rows.find { |r| r.pid == line.id } || (line.rows.first if line.rows.size == 1)
51
+ return "#{line.label}\n#{line.rows.size} processes: ungroup (g) to pick one" unless row
52
+
53
+ reading = engine.current
54
+ lines(row, command(app, row), now: reading.at, width:, reading:).join("\n")
55
+ end
56
+
57
+ # The process's own lines, then each registered `Agentmon.detail_section`'s.
58
+ def lines(row, command, now:, width:, reading: nil, sections: Agentmon.registry.detail_sections)
59
+ [*own_lines(row, command, now:, width:), *sections.flat_map { |s| section_lines(s, row, reading) }]
60
+ end
61
+
62
+ # A section that raises shows its error instead of breaking the panel.
63
+ def section_lines(section, row, reading)
64
+ section.block.call(row, reading).map { |label, value| pair(label.to_s, value || "unknown") }
65
+ rescue StandardError => e
66
+ [pair(section.name.to_s, "#{e.class}: #{e.message}")]
67
+ end
68
+
69
+ def own_lines(row, command, now:, width:)
70
+ readable = row.readable
71
+ rusage = ->(value) { readable ? value : UNREADABLE }
72
+ files = file_counter.call(row.pid)
73
+ [
74
+ "#{row.name} pid #{row.pid}",
75
+ *wrap("Command", command || row.path, width),
76
+ pair("Directory", row.cwd ? R2UI::Format.short_path(row.cwd) : "unknown"),
77
+ pair("Session", row.session || "none"),
78
+ pair("Footprint", rusage[bytes(row.footprint)]),
79
+ pair("Resident", bytes(row.resident)),
80
+ pair("Peak", rusage[bytes(row.peak_footprint)]),
81
+ pair("CPU time", rusage[row.cpu_time && R2UI::CLI::Ext::HumanFormat.duration(row.cpu_time)]),
82
+ pair("Written", rusage[bytes(row.disk_written)]),
83
+ pair("Started", rusage[row.started_at && "#{R2UI::CLI::Ext::HumanFormat.duration([now - row.started_at, 0].max)} ago"]),
84
+ pair("Open files", files ? files.to_s : (readable ? "unknown" : UNREADABLE))
85
+ ]
86
+ end
87
+
88
+ # The full command line of `pid` (one ps), or nil when it has exited.
89
+ def read_command(pid)
90
+ text = IO.popen(["ps", "-o", "args=", "-p", pid.to_s], err: File::NULL, &:read).strip
91
+ text.empty? ? nil : text
92
+ rescue SystemCallError
93
+ nil
94
+ end
95
+
96
+ # How many file descriptors `pid` has open, or nil when unreadable (EPERM, exited).
97
+ def open_files(pid)
98
+ return nil unless Darwin.available?
99
+
100
+ estimate = pidinfo.call(pid, PROC_PIDLISTFDS, 0, nil, 0)
101
+ return nil unless estimate.positive?
102
+
103
+ buffer = Fiddle::Pointer.malloc(estimate, Fiddle::RUBY_FREE)
104
+ used = pidinfo.call(pid, PROC_PIDLISTFDS, 0, buffer, estimate)
105
+ used.positive? ? used / FDINFO_SIZE : nil
106
+ end
107
+
108
+ private
109
+
110
+ # The selected process's command line, read once per selection (pid and start time).
111
+ def command(app, row)
112
+ cache = app.store(:agentmon_process_detail)
113
+ key = [row.pid, row.started_at]
114
+ unless cache[:key] == key
115
+ cache[:key] = key
116
+ cache[:command] = command_reader.call(row.pid)
117
+ end
118
+ cache[:command]
119
+ end
120
+
121
+ def bytes(value) = value && R2UI::Format.bytes(value)
122
+
123
+ def pair(label, value) = "#{label.ljust(LABEL_WIDTH)}#{value}"
124
+
125
+ # The command line over up to COMMAND_LINES lines, indented under its value column.
126
+ def wrap(label, text, width)
127
+ room = [width - LABEL_WIDTH, 10].max
128
+ chunks = text.to_s.scan(/.{1,#{room}}/).first(COMMAND_LINES)
129
+ chunks = [""] if chunks.empty?
130
+ chunks.each_with_index.map { |chunk, i| pair(i.zero? ? label : "", chunk) }
131
+ end
132
+
133
+ # proc_pidinfo, bound here: Darwin's binding is private to the core.
134
+ def pidinfo
135
+ @pidinfo ||= Fiddle::Function.new(
136
+ Fiddle.dlopen(nil)["proc_pidinfo"],
137
+ [Fiddle::TYPE_INT, Fiddle::TYPE_INT, -Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
138
+ Fiddle::TYPE_INT
139
+ )
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ panel :detail, row: :main, order: 200, span: 1, resource: nil do |engine|
146
+ view { UI::ProcessDetail.render(app, engine, width) }
147
+ end
148
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Network columns on the :process resource ("Net in", "Net out": each process's bytes per second
4
+ # received and sent, blank while unknown), on performance views only (--layout): the default
5
+ # dashboard keeps its columns.
6
+ module Agentmon
7
+ extend_resource :process do |_engine|
8
+ next unless Agentmon::Views.installing
9
+
10
+ column :net_in_rate, label: "Net in", format: :bytes_per_sec
11
+ column :net_out_rate, label: "Net out", format: :bytes_per_sec
12
+ end
13
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Quick filters on the Processes table, after Agents and All (cycle with `]`, back with `[`):
4
+ #
5
+ # Busy CPU above 5% of one core
6
+ # Heavy footprint above 500 MiB
7
+ # Writing writing to disk now (write rate above 0 B/s)
8
+ #
9
+ # They look at every process, not just agent ones, and `/` search still narrows inside them
10
+ # (Busy, then `/node`: the busy node processes). A process whose value is unknown (unreadable
11
+ # without root, or not yet sampled twice) is left out of that scope.
12
+ module Agentmon
13
+ module UI
14
+ # The thresholds, in the units of ProcessRow: percent of one core, bytes.
15
+ module ProcessScopes
16
+ BUSY_CPU = 5.0
17
+ HEAVY_FOOTPRINT = 500 * 1024 * 1024
18
+
19
+ module_function
20
+
21
+ def busy?(row) = !row.cpu.nil? && row.cpu > BUSY_CPU
22
+ def heavy?(row) = !row.footprint.nil? && row.footprint > HEAVY_FOOTPRINT
23
+ def writing?(row) = !row.write_rate.nil? && row.write_rate.positive?
24
+ end
25
+ end
26
+
27
+ extend_resource :process do |_engine|
28
+ scope(:busy) { |row| UI::ProcessScopes.busy?(row) }
29
+ scope(:heavy) { |row| UI::ProcessScopes.heavy?(row) }
30
+ scope(:writing) { |row| UI::ProcessScopes.writing?(row) }
31
+ end
32
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What the selected process waits on: memory and scheduling detail in the Detail panel, two compact
4
+ # columns in the Processes table, and a Waiting scope.
5
+ #
6
+ # Detail panel lines, under the process's own (Detail follows the Processes selection):
7
+ #
8
+ # Threads 12 (1 running)
9
+ # CPU wait 25.0% now, 3m 2s total runnable but waiting for a CPU (% of one core)
10
+ # Page-ins 10/s, 20 total each one waited for the disk
11
+ # Faults 1000/s (COW 100/s)
12
+ # Switches 500/s context switches
13
+ # Wired 1.0M
14
+ # Read 512B/s, 1.2G total from disk
15
+ #
16
+ # A value that isn't known yet (rates need two samples) is left out of its line, and a line with
17
+ # nothing known says "unknown". Root's processes say "not readable without root".
18
+ #
19
+ # Processes table: Wait (`run_wait`, percent of one core spent waiting for a CPU) and Pgin/s
20
+ # (page-ins per second); blank when unknown. Scope Waiting (after Busy, Heavy, Writing): processes
21
+ # waiting for a CPU more than 10% of a core, or paging in at all. macOS has no per-process I/O wait
22
+ # without root; page-ins and disk rates are the closest signals.
23
+ module Agentmon
24
+ module UI
25
+ module ProcessWaits
26
+ UNREADABLE = "not readable without root"
27
+ # Scope Waiting: percent of one core spent runnable but not running.
28
+ WAITING_RUN_WAIT = 10.0
29
+ # Fixed widths for the two table columns (r2ui has no optional columns, so keep them narrow).
30
+ WAIT_WIDTH = 6
31
+ PAGEIN_WIDTH = 6
32
+
33
+ module_function
34
+
35
+ def waiting?(row)
36
+ (!row.run_wait.nil? && row.run_wait > WAITING_RUN_WAIT) || (!row.pagein_rate.nil? && row.pagein_rate.positive?)
37
+ end
38
+
39
+ # The Detail panel lines for `row`: [[label, value or nil], ...].
40
+ def lines(row)
41
+ labels = ["Threads", "CPU wait", "Page-ins", "Faults", "Switches", "Wired", "Read"]
42
+ return labels.map { |l| [l, UNREADABLE] } unless row.readable
43
+
44
+ labels.zip([threads(row), cpu_wait(row), pageins(row), faults(row), rate(row.context_switch_rate),
45
+ row.wired && R2UI::Format.bytes(row.wired), read(row)])
46
+ end
47
+
48
+ def threads(row)
49
+ return nil unless row.threads
50
+
51
+ row.running_threads ? "#{row.threads} (#{row.running_threads} running)" : row.threads.to_s
52
+ end
53
+
54
+ # "25.0% now, 3m 2s total": total is runnable time minus time on a CPU.
55
+ def cpu_wait(row)
56
+ total = row.runnable_time && row.cpu_time && [row.runnable_time - row.cpu_time, 0.0].max
57
+ join(row.run_wait && "#{format('%.1f%%', row.run_wait)} now",
58
+ total && "#{R2UI::CLI::Ext::HumanFormat.duration(total)} total")
59
+ end
60
+
61
+ def pageins(row) = join(rate(row.pagein_rate), row.pageins && "#{row.pageins} total")
62
+
63
+ def faults(row)
64
+ cow = rate(row.cow_fault_rate)
65
+ base = rate(row.fault_rate)
66
+ return base && "#{base} (COW #{cow})" if cow
67
+
68
+ base
69
+ end
70
+
71
+ def read(row)
72
+ join(row.read_rate && "#{R2UI::Format.bytes(row.read_rate)}/s",
73
+ row.disk_read && "#{R2UI::Format.bytes(row.disk_read)} total")
74
+ end
75
+
76
+ # Events per second: whole numbers from 10/s up, one decimal below.
77
+ def rate(value)
78
+ return nil unless value
79
+
80
+ value >= 10 || value.zero? ? "#{value.round}/s" : format("%.1f/s", value)
81
+ end
82
+
83
+ def join(*parts)
84
+ known = parts.compact
85
+ known.empty? ? nil : known.join(", ")
86
+ end
87
+ end
88
+ end
89
+
90
+ detail_section :waits, order: 10 do |row, _reading|
91
+ UI::ProcessWaits.lines(row)
92
+ end
93
+
94
+ extend_resource :process do |_engine|
95
+ column :run_wait, label: "Wait", format: :percent, width: UI::ProcessWaits::WAIT_WIDTH
96
+ column :pagein_rate, label: "Pgin/s", format: :number, width: UI::ProcessWaits::PAGEIN_WIDTH
97
+ scope(:waiting) { |row| UI::ProcessWaits.waiting?(row) }
98
+ end
99
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The :process resource and the Processes panel (the main row): every process with its session,
4
+ # directory, CPU %, footprint (what Activity Monitor calls Memory), resident size and disk rates.
5
+ # Scope "Agents" (default) shows processes in agent sessions; "All" shows everything. Group by
6
+ # session, directory, name, or as a tree (g); search with / (`footprint>500M`, `session~repo`).
7
+ #
8
+ # Other stories add to the resource with `Agentmon.extend_resource(:process) { ... }` (actions,
9
+ # scopes, columns) instead of editing this file.
10
+ module Agentmon
11
+ resource :process do |engine|
12
+ title "Processes"
13
+ source { engine.current[:process_rows] || [] }
14
+ refresh every: engine.interval
15
+ key :pid, parent: :ppid
16
+
17
+ scope :agents, default: true, &:session
18
+ scope :all
19
+
20
+ group_by :session, label: "Session"
21
+ group_by :cwd, label: "Directory"
22
+ group_by :name
23
+ group_by :parent, tree: true
24
+
25
+ index do
26
+ column :pid, format: :id
27
+ column :name
28
+ column :session, label: "Session"
29
+ column :cwd, label: "Directory", format: :short_path
30
+ column :cpu, label: "CPU", format: :percent, sparkline: true, sort: :desc
31
+ column :footprint, label: "Footprint", format: :bytes
32
+ column :resident, label: "Resident", format: :bytes
33
+ column :read_rate, label: "Read", format: :bytes_per_sec
34
+ column :write_rate, label: "Write", format: :bytes_per_sec
35
+ end
36
+
37
+ filter :name, :cwd, :session, :path
38
+ end
39
+
40
+ # span 3: a detail pane beside it (span 1) takes a quarter of the row.
41
+ panel :process, row: :main, order: 100, span: 3
42
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drill into one agent session: every panel narrows to it.
4
+ #
5
+ # enter on a Sessions row: focus that session (anywhere else enter still toggles a group)
6
+ # F focus the session of the row selected in Processes ("not in an agent session" if none)
7
+ # escape back to every session
8
+ #
9
+ # Focus lives in the core (`engine.focus=`, lib/agentmon/focus.rb): panels keep reading
10
+ # `engine.current[...]`, which narrows itself, so Processes, Sessions and the pressure drivers show
11
+ # only the focused session once their feeds refresh. Each change refreshes every feed at once, so
12
+ # the next frame is already narrowed. While focused the status bar says
13
+ #
14
+ # focus: claude 4242 · repo (esc: all)
15
+ #
16
+ # and a focused session that has left the ledger clears itself (and refreshes) on the next frame.
17
+ module Agentmon
18
+ module UI
19
+ module SessionFocus
20
+ SESSIONS = :session
21
+ PROCESSES = :process
22
+ NO_SESSION = "not in an agent session"
23
+ SEVERAL = "rows span several sessions: ungroup (g) to pick one"
24
+
25
+ module_function
26
+
27
+ # Sets the engine's focus (nil clears it) and refreshes every feed, so the next frame shows it.
28
+ def focus!(app, engine, session_id)
29
+ engine.focus = session_id
30
+ app.feeds.each_value(&:refresh!)
31
+ end
32
+
33
+ # The session id of the line selected in the Sessions panel; nil on a group line or none.
34
+ def selected_session(app)
35
+ line = selected_line(app, SESSIONS)
36
+ return nil if line.nil? || line.id.is_a?(Array) # [:group, by, key]: enter toggles it
37
+
38
+ line.id
39
+ end
40
+
41
+ # The session of the line selected in Processes: [session_id, nil] or [nil, message to flash].
42
+ # A group line focuses its session when every process in it is in that one session.
43
+ def process_session(app)
44
+ line = selected_line(app, PROCESSES) or return [nil, nil]
45
+
46
+ row = line.rows.find { |r| r.pid == line.id }
47
+ ids = (row ? [row] : line.rows).map(&:session_id).uniq
48
+ return [ids.first, nil] if ids.size == 1 && ids.first
49
+ return [nil, NO_SESSION] if ids.compact.empty?
50
+
51
+ [nil, SEVERAL]
52
+ end
53
+
54
+ # The status bar text while focused; nil when unfocused.
55
+ def status(engine)
56
+ session = engine.focused_session
57
+ session && "focus: #{session.label} (esc: all)"
58
+ end
59
+
60
+ # Clears a focus whose session has left the ledger (and refreshes). True when it did. A
61
+ # reading without a ledger (that metric failed this sample) keeps the focus.
62
+ def clear_if_gone(app, engine)
63
+ id = engine.focus or return false
64
+ ledger = engine.current(focused: false)[:session_ledger]
65
+ return false if ledger.nil? || ledger.any? { |s| s.id == id }
66
+
67
+ focus!(app, engine, nil)
68
+ true
69
+ end
70
+
71
+ def selected_line(app, name)
72
+ panel = app.dashboard.panels.find { |p| p.name == name }
73
+ state = panel && app.panel_state(panel)
74
+ state && app.panel_lines(panel)[state.selected]
75
+ end
76
+ end
77
+ end
78
+
79
+ # Hands the engine to the extension below through the dashboard, so only a dashboard built by
80
+ # UI.install (with its own engine) gets these keys.
81
+ R2UI.extension :agentmon_session_focus do
82
+ dsl :dashboard do
83
+ def agentmon_session_focus(engine) = declare(:agentmon_session_focus, engine)
84
+ end
85
+
86
+ helpers do
87
+ # The engine of the dashboard drawing, nil on dashboards that aren't agentmon's.
88
+ def session_focus_engine = dashboard.declared(:agentmon_session_focus).first
89
+ end
90
+
91
+ on(->(m) { m.is_a?(Bubbletea::KeyMessage) && [:enter, "F", :escape].include?(R2UI::Keys.name(m)) }) do |message|
92
+ engine = session_focus_engine or pass
93
+ focus = UI::SessionFocus
94
+ case R2UI::Keys.name(message)
95
+ when :enter
96
+ pass unless app.focus&.name == focus::SESSIONS
97
+ id = focus.selected_session(app) or pass
98
+ focus.focus!(app, engine, id)
99
+ when "F"
100
+ id, problem = focus.process_session(app)
101
+ id ? focus.focus!(app, engine, id) : (problem && flash(problem))
102
+ when :escape
103
+ pass unless engine.focus
104
+ focus.focus!(app, engine, nil)
105
+ end
106
+ nil
107
+ end
108
+
109
+ # Feeds' own refreshes arrive as updates too: a session that left the ledger clears here even
110
+ # while a flash hides the status bar.
111
+ after_update do
112
+ engine = session_focus_engine
113
+ UI::SessionFocus.clear_if_gone(app, engine) if engine
114
+ end
115
+
116
+ status do
117
+ engine = session_focus_engine
118
+ next nil unless engine
119
+
120
+ UI::SessionFocus.clear_if_gone(app, engine)
121
+ UI::SessionFocus.status(engine)
122
+ end
123
+ end
124
+
125
+ dashboard { |engine| agentmon_session_focus(engine) }
126
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The :session_memory resource and the "Memory by session" panel (top row, right): how much real
4
+ # memory each alive agent session holds, largest first, and whether it is growing or paging.
5
+ #
6
+ # ╭─ Memory by session ──────────────────────────────────────────────────╮
7
+ # │compressed 2.0G · swap 512M (per-process compressed/swap needs root) │
8
+ # │Session Footprint▼ Share Resident Growth Pgin/s│
9
+ # │claude 4242 · repo ▂▃▅▇ 2.1G 26.3% 2.4G +1.5M/s 10.0│
10
+ # │Claude 59334 ▅▅▅▅ 900M 11.0% 1.1G -12K/s 0.0│
11
+ # ╰──────────────────────────────────────────────────────────────────────╯
12
+ #
13
+ # Footprint is what Activity Monitor calls Memory (it includes the session's compressed pages),
14
+ # summed over the session's processes, each once; Share is its percent of used memory; Growth is
15
+ # its footprint change per second over the last minute; Pgin/s counts page-ins per second (each
16
+ # one waited for the disk). The first line is the machine's compressed memory and swap used:
17
+ # macOS splits those out per process only for root. It is blank while memory is unavailable.
18
+ # A narrow panel drops columns from the right (150 columns show Session and Footprint;
19
+ # 220 add Share and Resident).
20
+ # Follows session focus (enter on a session): then only the focused session is listed.
21
+ module Agentmon
22
+ module UI
23
+ module SessionMemoryPanel
24
+ NOTE = "(per-process compressed/swap needs root)"
25
+
26
+ module_function
27
+
28
+ # reading[:session_memory] as rows; none while it is unavailable.
29
+ def rows(reading)
30
+ (reading[:session_memory] || []).map do |m|
31
+ { id: m.session_id, label: m.label, footprint: m.footprint, share: m.share, resident: m.resident,
32
+ growth_rate: m.growth_rate, growth: growth(m.growth_rate), pagein_rate: m.pagein_rate }
33
+ end
34
+ end
35
+
36
+ # "+1.5M/s", "-12K/s"; "" when unknown. (r2ui's :bytes_per_sec has no sign.)
37
+ def growth(rate)
38
+ return "" if rate.nil?
39
+
40
+ "#{rate.negative? ? "-" : "+"}#{R2UI::Format.bytes(rate.abs)}/s"
41
+ end
42
+
43
+ # "compressed 2.0G · swap 512M (per-process compressed/swap needs root)", cut to `width`;
44
+ # "" without a MemoryView.
45
+ def machine_line(memory, width)
46
+ return "" unless memory
47
+
48
+ f = R2UI::Format
49
+ text = "compressed #{f.bytes(memory.compressed.to_i)} · swap #{f.bytes(memory.swap_used.to_i)} #{NOTE}"
50
+ text.length > width ? "#{text[0, [width - 1, 0].max]}…" : text
51
+ end
52
+ end
53
+ end
54
+
55
+ resource :session_memory do |engine|
56
+ title "Memory by session"
57
+ source { UI::SessionMemoryPanel.rows(engine.current) }
58
+ refresh every: engine.interval
59
+ key :id
60
+
61
+ index do
62
+ column :label, label: "Session"
63
+ column :footprint, label: "Footprint", format: :bytes, sparkline: true, sort: :desc
64
+ column :share, label: "Share", format: :percent
65
+ column :resident, label: "Resident", format: :bytes
66
+ column :growth, label: "Growth", width: 8, align: :right
67
+ column :pagein_rate, label: "Pgin/s", format: :number, width: 6
68
+ end
69
+
70
+ filter :label
71
+ end
72
+
73
+ panel :session_memory, row: :top, order: 30, span: 1, title: "Memory by session" do |engine|
74
+ # Above the table: r2ui's table takes all the height left, so nothing after it is drawn.
75
+ view { UI::SessionMemoryPanel.machine_line(engine.current(max_age: Float::INFINITY)[:memory], width) }
76
+ table
77
+ end
78
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The :session resource and the Sessions panel (top row, beside the memory panel): one line per
4
+ # agent session with its process count, CPU % and footprint now (with sparklines), peak footprint,
5
+ # CPU seconds and bytes written over its life, disk rates now and its age. Sorted by CPU.
6
+ # Scope "Live" (default) shows running sessions; "All" (]) adds the ones that ended in the last
7
+ # 15 minutes, labelled with when:
8
+ #
9
+ # Session Procs CPU Footprint Peak CPU s Written Read Write Age
10
+ # claude 4242 · repo 4 150.0% 628M 700M 15.6 5.0M 0B/s 1000B/s 56m
11
+ # claude 4310 · web · ended 3m ago 0 0.0% 0B 150M 5.0 0B 0B/s 0B/s 1h 2m
12
+ #
13
+ # Age is how long a session has run: since its root process started, until it ended.
14
+ # Search with / (`label~repo`, `footprint>1G`).
15
+ module Agentmon
16
+ module UI
17
+ # Turns the ledger's Session records into the panel's rows.
18
+ module SessionsPanel
19
+ module_function
20
+
21
+ NO_NETWORK = { net_in_rate: nil, net_out_rate: nil, bytes_in: nil, bytes_out: nil, connections: nil,
22
+ remote_hosts: nil, net_flags: [].freeze }.freeze
23
+
24
+ # reading[:session_ledger] as rows; none while no ledger is available. Each row also carries
25
+ # its session's network (reading[:session_network], matched by session id): nil values and
26
+ # no flags while network data is unknown or the session has no entry.
27
+ def rows(reading)
28
+ now = reading.at
29
+ network = (reading[:session_network] || []).to_h { |n| [n.session_id, n] }
30
+ (reading[:session_ledger] || []).map { |s| row(s, now, network[s.id]) }
31
+ end
32
+
33
+ def row(session, now, network = nil)
34
+ ended = session.ended_at
35
+ {
36
+ id: session.id,
37
+ alive: session.alive?,
38
+ label: ended ? "#{session.label} · ended #{age(now - ended)} ago" : session.label,
39
+ cwd: session.cwd,
40
+ search: session.label, # uncut, for `/` (not a column)
41
+ title: session.title, # human name (also at the front of label); nil when unknown
42
+ status: session.status, # Claude Code's "busy"/"idle"; nil when unknown
43
+ processes: session.processes,
44
+ cpu: session.cpu,
45
+ footprint: session.footprint,
46
+ peak_footprint: session.peak_footprint,
47
+ cpu_seconds: session.cpu_seconds,
48
+ bytes_written: session.bytes_written,
49
+ read_rate: session.read_rate,
50
+ write_rate: session.write_rate,
51
+ age: age((ended || now) - (session.started_at || session.first_seen_at)),
52
+ **network_values(network)
53
+ }
54
+ end
55
+
56
+ # A SessionNetwork as row values (NO_NETWORK for nil).
57
+ def network_values(n)
58
+ return NO_NETWORK unless n
59
+
60
+ { net_in_rate: n.in_rate, net_out_rate: n.out_rate, bytes_in: n.bytes_in, bytes_out: n.bytes_out,
61
+ connections: n.connections, remote_hosts: n.remote_hosts, net_flags: Array(n.flags) }
62
+ end
63
+
64
+ # "45s", "12m", "3h 5m", "2d 4h"; "" when unknown.
65
+ def age(seconds)
66
+ return "" if seconds.nil?
67
+
68
+ s = [seconds.to_i, 0].max
69
+ if s < 60 then "#{s}s"
70
+ elsif s < 3600 then "#{s / 60}m"
71
+ elsif s < 86_400 then "#{s / 3600}h #{s % 3600 / 60}m"
72
+ else "#{s / 86_400}d #{s % 86_400 / 3600}h"
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ resource :session do |engine|
79
+ title "Sessions"
80
+ source { UI::SessionsPanel.rows(engine.current) }
81
+ refresh every: engine.interval
82
+ key :id
83
+
84
+ scope :live, default: true do |row|
85
+ row[:alive]
86
+ end
87
+ scope :all
88
+
89
+ index do
90
+ column :label, label: "Session"
91
+ column :processes, label: "Procs", format: :integer
92
+ column :cpu, label: "CPU", format: :percent, sparkline: true, sort: :desc
93
+ column :footprint, label: "Footprint", format: :bytes, sparkline: true
94
+ column :peak_footprint, label: "Peak", format: :bytes
95
+ column :cpu_seconds, label: "CPU s", format: :number
96
+ column :bytes_written, label: "Written", format: :bytes
97
+ column :read_rate, label: "Read", format: :bytes_per_sec
98
+ column :write_rate, label: "Write", format: :bytes_per_sec
99
+ column :age, label: "Age", width: 7, align: :right # fixed, so the label takes the spare width
100
+ end
101
+
102
+ # `/` matches the whole label (name included), not the Session column's text: r2ui's search
103
+ # reads a column through its reader (search.rb:31), and a view cuts that one to its width.
104
+ filter :search, :cwd
105
+ end
106
+
107
+ # Network columns, on performance views only (--layout): the default dashboard keeps its columns.
108
+ extend_resource :session do |_engine|
109
+ next unless Agentmon::Views.installing
110
+
111
+ column :net_in_rate, label: "Net in", format: :bytes_per_sec
112
+ column :net_out_rate, label: "Net out", format: :bytes_per_sec
113
+ column :bytes_in, label: "Recv", format: :bytes
114
+ column :bytes_out, label: "Sent", format: :bytes
115
+ column :connections, label: "Conns", format: :integer
116
+ column :remote_hosts, label: "Hosts", format: :integer
117
+ end
118
+
119
+ panel :session, row: :top, order: 20, span: 2
120
+
121
+ # r2ui focuses the first table panel, which is now Sessions (top row). Keys and the first
122
+ # frame belong to Processes, so give it focus when the agentmon dashboard starts. Stopgap
123
+ # until the core focuses :process itself.
124
+ R2UI.extension :agentmon_sessions_focus do
125
+ setup do
126
+ process = dashboard.name == UI::DASHBOARD && dashboard.panels.find { |p| p.name == :process }
127
+ app.focus = process if process
128
+ end
129
+ end
130
+ end