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
|
@@ -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
|