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,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Session history: store kind `sessions`. While agentmon records (the dashboard, `agentmon
|
|
4
|
+
# record`), every 10 s it appends one line per alive agent session, and one final line for each
|
|
5
|
+
# session the first time it is seen ended. Each line is `Session#to_record` plus `t` (the sample's
|
|
6
|
+
# time) and `run` (the engine's run id), in the model's units (bytes, seconds, epoch seconds):
|
|
7
|
+
#
|
|
8
|
+
# ~/.local/state/agentmon/sessions/2026-10-01.jsonl
|
|
9
|
+
# {"t":1790711098.2,"id":"claude-4242-1790711088","kind":"cli","name":"claude","root_pid":4242,
|
|
10
|
+
# "label":"claude 4242 · repo","cwd":"/Users/me/src/repo","started_at":1790711088.4,...,
|
|
11
|
+
# "ended_at":null,"cpu_seconds":12.6,"bytes_read":0,"bytes_written":5244880,"run":"812-1790711090"}
|
|
12
|
+
#
|
|
13
|
+
# Readers merge lines by `id`, taking each total's maximum (docs/design.md, "Store records").
|
|
14
|
+
# Nothing is written while the session ledger is unavailable.
|
|
15
|
+
module Agentmon
|
|
16
|
+
module Recorders
|
|
17
|
+
module SessionHistory
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Records for this reading. `state[:ended]` holds the ids this engine already wrote a final
|
|
21
|
+
# line for, kept only while the ledger still lists them (it drops ended sessions after
|
|
22
|
+
# SessionLedger::KEEP_ENDED), so it stays small.
|
|
23
|
+
def call(reading, state)
|
|
24
|
+
ledger = reading[:session_ledger] or return []
|
|
25
|
+
|
|
26
|
+
written = state[:ended] ||= {}
|
|
27
|
+
ended_now = ledger.reject(&:alive?).to_h { |s| [s.id, true] }
|
|
28
|
+
written.keep_if { |id, _| ended_now.key?(id) }
|
|
29
|
+
ledger.filter_map do |session|
|
|
30
|
+
next if !session.alive? && written.key?(session.id)
|
|
31
|
+
|
|
32
|
+
written[session.id] = true unless session.alive?
|
|
33
|
+
session.to_record.merge(t: reading.at)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
recorder(:sessions, every: 10) { |reading, state| Recorders::SessionHistory.call(reading, state) }
|
|
40
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentmon
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# A probe reads the machine. Its block gets a private state Hash (kept between samples) and
|
|
7
|
+
# returns one value, stored as `sample[name]`. With `every:`, the previous value is reused until
|
|
8
|
+
# that many seconds have passed (slow probes such as lsof).
|
|
9
|
+
Probe = Data.define(:name, :every, :block)
|
|
10
|
+
# A metric derives a value from samples. Its block gets the Reading and a private state Hash
|
|
11
|
+
# (kept between samples, so it can accumulate) and returns `reading[name]`. Every metric runs on
|
|
12
|
+
# every sample, so stateful ones see each one. A metric that raises is nil for that sample (its
|
|
13
|
+
# error shows in Engine#errors), and `reading[:other]` is nil while no story registers `other`.
|
|
14
|
+
Metric = Data.define(:name, :block)
|
|
15
|
+
# A recorder turns a Reading into store records (a Hash or an Array of them) appended under its
|
|
16
|
+
# name, at most once every `every` seconds, while the engine records (the dashboard,
|
|
17
|
+
# `agentmon record`). Its block gets the Reading and a private state Hash kept for the engine's
|
|
18
|
+
# life (e.g. the ids it already wrote a final line for): `{ |reading, state| ... }`.
|
|
19
|
+
Recorder = Data.define(:name, :every, :block)
|
|
20
|
+
# A dashboard row. Panels name the row they go in; rows without panels are left out.
|
|
21
|
+
Row = Data.define(:name, :order, :height)
|
|
22
|
+
# A dashboard panel: r2ui's `panel` arguments plus where it goes. `block` holds r2ui panel items
|
|
23
|
+
# (table, gauge, stat, sparkline, view, ...); nil shows the resource's full index.
|
|
24
|
+
Panel = Data.define(:name, :row, :order, :resource, :span, :title, :options, :block)
|
|
25
|
+
# A CLI command: the block is an r2ui CLI command body (summary, option, run, ...).
|
|
26
|
+
Command = Data.define(:name, :block)
|
|
27
|
+
# Lines a story adds to the Detail panel (ui/process_detail.rb) under the selected process's
|
|
28
|
+
# own: the block gets the ProcessRow and the Engine's current Reading and returns
|
|
29
|
+
# `[[label, value], ...]` (value a String; nil shows "unknown"), or [] to add nothing.
|
|
30
|
+
DetailSection = Data.define(:name, :order, :block)
|
|
31
|
+
|
|
32
|
+
# Everything the extension files registered. One per process (Agentmon.registry); tests build
|
|
33
|
+
# their own to try a registration in isolation.
|
|
34
|
+
class Registry
|
|
35
|
+
KINDS = %i[probe metric recorder row panel command detail_section view].freeze
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@items = KINDS.to_h { |k| [k, {}] }
|
|
39
|
+
@resources = {}
|
|
40
|
+
@resource_extensions = Hash.new { |h, k| h[k] = [] }
|
|
41
|
+
@dashboard_blocks = []
|
|
42
|
+
@cli_blocks = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
attr_reader :resource_extensions, :dashboard_blocks, :cli_blocks
|
|
46
|
+
|
|
47
|
+
# Adds a named item; a name already taken in its kind raises, so two stories can't silently
|
|
48
|
+
# register the same probe, metric, panel or command.
|
|
49
|
+
def add(kind, item)
|
|
50
|
+
items = @items.fetch(kind) { raise Error, "unknown kind #{kind.inspect}" }
|
|
51
|
+
raise Error, "#{kind} #{item.name} is already registered" if items.key?(item.name)
|
|
52
|
+
|
|
53
|
+
items[item.name] = item
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def all(kind) = @items.fetch(kind).values
|
|
57
|
+
|
|
58
|
+
def [](kind, name) = @items.fetch(kind)[name.to_sym]
|
|
59
|
+
|
|
60
|
+
def probes = all(:probe)
|
|
61
|
+
def metrics = all(:metric)
|
|
62
|
+
def recorders = all(:recorder)
|
|
63
|
+
def commands = all(:command)
|
|
64
|
+
|
|
65
|
+
# Detail panel sections in order (then by name).
|
|
66
|
+
def detail_sections = all(:detail_section).sort_by { |s| [s.order, s.name.to_s] }
|
|
67
|
+
|
|
68
|
+
# Rows in order, each with its panels in order; empty rows left out.
|
|
69
|
+
def layout
|
|
70
|
+
panels = all(:panel).group_by(&:row)
|
|
71
|
+
unknown = panels.keys - @items[:row].keys
|
|
72
|
+
raise Error, "panel #{panels[unknown.first].first.name} names unknown row #{unknown.first}" if unknown.any?
|
|
73
|
+
|
|
74
|
+
all(:row).sort_by(&:order).filter_map do |row|
|
|
75
|
+
members = panels[row.name]
|
|
76
|
+
members && [row, members.sort_by { |p| [p.order, p.name.to_s] }]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def define_resource(name, block)
|
|
81
|
+
raise Error, "resource #{name} is already defined" if @resources.key?(name)
|
|
82
|
+
|
|
83
|
+
@resources[name] = block
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# name => [definition block, *extension blocks in load order]
|
|
87
|
+
def resources
|
|
88
|
+
extra = @resource_extensions.keys - @resources.keys
|
|
89
|
+
raise Error, "extend_resource #{extra.first}: no resource defines it" if extra.any?
|
|
90
|
+
|
|
91
|
+
@resources.to_h { |name, block| [name, [block, *@resource_extensions[name]]] }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class << self
|
|
96
|
+
def registry = @registry ||= Registry.new
|
|
97
|
+
|
|
98
|
+
# --- the extension points (docs/design.md); each story's file calls these at load ---
|
|
99
|
+
|
|
100
|
+
def probe(name, every: nil, &block) = registry.add(:probe, Probe.new(name: name.to_sym, every:, block: need(block)))
|
|
101
|
+
|
|
102
|
+
def metric(name, &block) = registry.add(:metric, Metric.new(name: name.to_sym, block: need(block)))
|
|
103
|
+
|
|
104
|
+
def recorder(name, every: 10, &block)
|
|
105
|
+
registry.add(:recorder, Recorder.new(name: name.to_sym, every:, block: need(block)))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def row(name, order:, height: nil) = registry.add(:row, Row.new(name: name.to_sym, order:, height:))
|
|
109
|
+
|
|
110
|
+
# `resource:` defaults to the panel's name, as in r2ui; `resource: nil` for extension items only.
|
|
111
|
+
def panel(name, row:, order: 100, resource: name, span: 1, title: nil, **options, &block)
|
|
112
|
+
registry.add(:panel, Panel.new(name: name.to_sym, row: row.to_sym, order:, resource:, span:, title:, options:, block:))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# An r2ui resource (R2UI.resource's DSL). The block runs on r2ui's resource builder and gets
|
|
116
|
+
# the Engine: `source { engine.current[:process_rows] }`. Defined once; see extend_resource.
|
|
117
|
+
def resource(name, &block) = registry.define_resource(name.to_sym, need(block))
|
|
118
|
+
|
|
119
|
+
# More DSL for a resource another file defines: columns, scopes, actions. Runs after the
|
|
120
|
+
# definition, in load order.
|
|
121
|
+
def extend_resource(name, &block) = registry.resource_extensions[name.to_sym] << need(block)
|
|
122
|
+
|
|
123
|
+
# Dashboard-level r2ui DSL (keys, mouse, theme, title, ...), run on r2ui's dashboard builder
|
|
124
|
+
# with the Engine, before the rows.
|
|
125
|
+
def dashboard(&block) = registry.dashboard_blocks << need(block)
|
|
126
|
+
|
|
127
|
+
# An `agentmon <name>` command; the block is an r2ui CLI command body.
|
|
128
|
+
def command(name, &block) = registry.add(:command, Command.new(name: name.to_sym, block: need(block)))
|
|
129
|
+
|
|
130
|
+
# More lines in the Detail panel for the selected process: `{ |row, reading| [[label, value]] }`.
|
|
131
|
+
def detail_section(name, order: 100, &block)
|
|
132
|
+
registry.add(:detail_section, DetailSection.new(name: name.to_sym, order:, block: need(block)))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A performance view (docs/views.md): rows of panels of view words, run with
|
|
136
|
+
# `agentmon --layout NAME`. The block runs on Views::ViewBuilder (lib/agentmon/views.rb).
|
|
137
|
+
def view(name, title: "agentmon", &block)
|
|
138
|
+
registry.add(:view, Views.build(name, title:, &need(block)))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Root-level r2ui CLI DSL (version, completion, global flags), run on the root builder.
|
|
142
|
+
def cli(&block) = registry.cli_blocks << need(block)
|
|
143
|
+
|
|
144
|
+
# Loads every extension file, kind by kind, each directory in name order.
|
|
145
|
+
def load_extensions(root = __dir__)
|
|
146
|
+
%w[probes metrics recorders ui commands].each do |dir|
|
|
147
|
+
Dir[File.join(root, dir, "*.rb")].sort.each { |file| require file }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def need(block) = block || raise(ArgumentError, "a block is required")
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentmon
|
|
4
|
+
# Runs every registered probe once and returns a Sample. A probe with `every:` keeps its last
|
|
5
|
+
# value until it is due again; a probe that raises keeps its last value and its error goes in
|
|
6
|
+
# `sample.errors`, so one broken probe never stops the others.
|
|
7
|
+
#
|
|
8
|
+
# Tests don't use this: they hand an Engine a FixtureSampler (anything with `#sample`).
|
|
9
|
+
class Sampler
|
|
10
|
+
def initialize(registry: Agentmon.registry, clock: Clock)
|
|
11
|
+
@registry = registry
|
|
12
|
+
@clock = clock
|
|
13
|
+
@states = Hash.new { |h, k| h[k] = {} }
|
|
14
|
+
@last = {} # probe name => [value, mono when taken]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def sample
|
|
18
|
+
mono = @clock.mono
|
|
19
|
+
at = @clock.now
|
|
20
|
+
errors = {}
|
|
21
|
+
parts = @registry.probes.to_h do |probe|
|
|
22
|
+
[probe.name, run(probe, mono, errors)]
|
|
23
|
+
end
|
|
24
|
+
Sample.new(at:, mono:, parts:, errors:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def run(probe, mono, errors)
|
|
30
|
+
last = @last[probe.name]
|
|
31
|
+
return last[0] if last && probe.every && mono - last[1] < probe.every
|
|
32
|
+
|
|
33
|
+
value = probe.block.call(@states[probe.name])
|
|
34
|
+
@last[probe.name] = [value, mono]
|
|
35
|
+
value
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
errors[probe.name] = "#{e.class}: #{e.message}"
|
|
38
|
+
last&.first
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The two clocks: wall time for instants people read, monotonic time for intervals and rates.
|
|
43
|
+
module Clock
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def now = Time.now.to_f
|
|
47
|
+
|
|
48
|
+
def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Agentmon
|
|
7
|
+
# Append-only history in plain files: one JSON object per line, one file per kind per local day.
|
|
8
|
+
#
|
|
9
|
+
# ~/.local/state/agentmon/sessions/2026-10-01.jsonl
|
|
10
|
+
# ~/.local/state/agentmon/memory/2026-10-01.jsonl
|
|
11
|
+
#
|
|
12
|
+
# The directory is $AGENTMON_STATE_DIR, else $XDG_STATE_HOME/agentmon, else
|
|
13
|
+
# ~/.local/state/agentmon. Every record gets `t` (epoch seconds) when it has none. Reading
|
|
14
|
+
# tolerates a torn last line (a crash mid-write) by skipping lines that don't parse.
|
|
15
|
+
class Store
|
|
16
|
+
def self.default_dir(env = ENV)
|
|
17
|
+
return File.expand_path(env["AGENTMON_STATE_DIR"]) if env["AGENTMON_STATE_DIR"].to_s != ""
|
|
18
|
+
|
|
19
|
+
base = env["XDG_STATE_HOME"].to_s == "" ? File.join(Dir.home, ".local", "state") : env["XDG_STATE_HOME"]
|
|
20
|
+
File.join(File.expand_path(base), "agentmon")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :dir
|
|
24
|
+
|
|
25
|
+
def initialize(dir: Store.default_dir, clock: Clock)
|
|
26
|
+
@dir = dir
|
|
27
|
+
@clock = clock
|
|
28
|
+
@lock = Mutex.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Appends one record (a Hash) under `kind`. Returns the record as written.
|
|
32
|
+
def append(kind, record)
|
|
33
|
+
record = { t: @clock.now }.merge(record)
|
|
34
|
+
path = file_for(kind, record[:t] || record["t"])
|
|
35
|
+
line = "#{JSON.generate(record)}\n"
|
|
36
|
+
@lock.synchronize do
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
38
|
+
File.open(path, "a") { |f| f.write(line) }
|
|
39
|
+
end
|
|
40
|
+
record
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Records of `kind` with `since <= t < till` (epoch seconds or Time), oldest first, keys as
|
|
44
|
+
# Symbols. Without a block, an Enumerator.
|
|
45
|
+
def each(kind, since: nil, till: nil, &block)
|
|
46
|
+
return enum_for(:each, kind, since:, till:) unless block
|
|
47
|
+
|
|
48
|
+
since = since&.to_f
|
|
49
|
+
till = till&.to_f
|
|
50
|
+
files(kind, since).each do |path|
|
|
51
|
+
File.foreach(path) do |line|
|
|
52
|
+
record = parse(line) or next
|
|
53
|
+
t = record[:t].to_f
|
|
54
|
+
next if (since && t < since) || (till && t >= till)
|
|
55
|
+
|
|
56
|
+
yield record
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Deletes day files older than `days` days. Returns the paths removed.
|
|
62
|
+
def prune(days:)
|
|
63
|
+
cutoff = day(@clock.now - (days * 86_400))
|
|
64
|
+
old = Dir[File.join(dir, "*", "*.jsonl")].select { |p| File.basename(p, ".jsonl") < cutoff }
|
|
65
|
+
old.each { |p| File.delete(p) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def file_for(kind, t) = File.join(dir, kind.to_s, "#{day(t)}.jsonl")
|
|
71
|
+
|
|
72
|
+
def day(t) = Time.at(t.to_f).strftime("%Y-%m-%d")
|
|
73
|
+
|
|
74
|
+
def files(kind, since)
|
|
75
|
+
all = Dir[File.join(dir, kind.to_s, "*.jsonl")].sort
|
|
76
|
+
# A record at `since` lives in that local day's file or a later one.
|
|
77
|
+
since ? all.select { |p| File.basename(p, ".jsonl") >= day(since) } : all
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse(line)
|
|
81
|
+
JSON.parse(line, symbolize_names: true)
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The :connection resource: the network flows of agent-session processes that have a concrete
|
|
4
|
+
# remote end, one line each, busiest upload first. No panel of its own: layouts place it with
|
|
5
|
+
# `top :connection`. Follows session focus (the reading is already narrowed).
|
|
6
|
+
#
|
|
7
|
+
# Process Proto Remote State In Out Recv Sent
|
|
8
|
+
# claude 4242 tcp4 api.anthropic.com:443 ESTABLISHED 2.0K/s 12K/s 1.5M 9.0M
|
|
9
|
+
# node 4310 tcp6 …ample-long-host.internal:8443 ESTABLISHED 0B/s 0B/s 10K 2K
|
|
10
|
+
#
|
|
11
|
+
# Remote is the host and port as the probe printed them, cut from the left when long (the port and
|
|
12
|
+
# domain matter more than a subdomain). Search with / (`remote~anthropic`, `state~CLOSE`).
|
|
13
|
+
module Agentmon
|
|
14
|
+
module UI
|
|
15
|
+
module ConnectionsPanel
|
|
16
|
+
REMOTE_WIDTH = 30
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# reading[:connections] as rows; none while network data is unavailable.
|
|
21
|
+
def rows(reading)
|
|
22
|
+
(reading[:connections] || []).map { |c| row(c) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def row(conn)
|
|
26
|
+
{
|
|
27
|
+
id: key(conn),
|
|
28
|
+
pid: conn.pid,
|
|
29
|
+
name: conn.name,
|
|
30
|
+
process: "#{conn.name} #{conn.pid}",
|
|
31
|
+
session_id: conn.session_id,
|
|
32
|
+
session: conn.session,
|
|
33
|
+
protocol: conn.protocol,
|
|
34
|
+
local: conn.local,
|
|
35
|
+
remote: conn.remote,
|
|
36
|
+
remote_text: cut_left(conn.remote.to_s, REMOTE_WIDTH),
|
|
37
|
+
interface: conn.interface,
|
|
38
|
+
state: conn.state,
|
|
39
|
+
in_rate: conn.in_rate,
|
|
40
|
+
out_rate: conn.out_rate,
|
|
41
|
+
bytes_in: conn.bytes_in,
|
|
42
|
+
bytes_out: conn.bytes_out
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Stable across samples while the flow lives: "pid|protocol|local|remote".
|
|
47
|
+
def key(conn) = [conn.pid, conn.protocol, conn.local, conn.remote].join("|")
|
|
48
|
+
|
|
49
|
+
# `text` fitted to `width` columns, dropping its start ("…" marks the cut).
|
|
50
|
+
def cut_left(text, width)
|
|
51
|
+
text.length > width ? "…#{text[-(width - 1)..]}" : text
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
resource :connection do |engine|
|
|
57
|
+
title "Connections"
|
|
58
|
+
source { UI::ConnectionsPanel.rows(engine.current) }
|
|
59
|
+
refresh every: engine.interval
|
|
60
|
+
key :id
|
|
61
|
+
|
|
62
|
+
index do
|
|
63
|
+
column :process, label: "Process"
|
|
64
|
+
column :protocol, label: "Proto", width: 5
|
|
65
|
+
column :remote_text, label: "Remote", width: UI::ConnectionsPanel::REMOTE_WIDTH
|
|
66
|
+
column :state, label: "State", width: 11
|
|
67
|
+
column :in_rate, label: "In", format: :bytes_per_sec
|
|
68
|
+
column :out_rate, label: "Out", format: :bytes_per_sec, sort: :desc
|
|
69
|
+
column :bytes_in, label: "Recv", format: :bytes
|
|
70
|
+
column :bytes_out, label: "Sent", format: :bytes
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
filter :name, :remote, :state
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mouse and help. A left click focuses the panel under the pointer and selects the table row
|
|
4
|
+
# there; the wheel moves the selection of the table under the pointer. `?` opens a full-screen
|
|
5
|
+
# help listing every key (r2ui's, those other stories bind, and agentmon's own) and what each
|
|
6
|
+
# panel shows; any key closes it.
|
|
7
|
+
#
|
|
8
|
+
# ? help click focus a panel, select a row
|
|
9
|
+
# K terminate the selection wheel move the selection
|
|
10
|
+
#
|
|
11
|
+
# The overlay is the r2ui extension `agentmon_help` (a `view_override`); it draws only for the
|
|
12
|
+
# agentmon dashboard, since r2ui's hooks are process-wide.
|
|
13
|
+
module Agentmon
|
|
14
|
+
module UI
|
|
15
|
+
# Not `Help` or `Interaction` alone: named after the story so it can't hide a core constant.
|
|
16
|
+
module MouseAndHelp
|
|
17
|
+
STORE = :agentmon_help
|
|
18
|
+
|
|
19
|
+
# agentmon's own keys, shown even before the stories that bind them have merged.
|
|
20
|
+
KEYS = [
|
|
21
|
+
["K", "terminate the selected process (TERM, asks y/n)"],
|
|
22
|
+
["X", "kill the selected process (KILL, asks y/n)"],
|
|
23
|
+
["?", "this help (any key closes it)"]
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# r2ui's selection keys, which its status bar doesn't list.
|
|
27
|
+
MOVE = [["↑ ↓ j k", "move the selection"]].freeze
|
|
28
|
+
|
|
29
|
+
MOUSE = [
|
|
30
|
+
["click", "focus the panel, select the row under the pointer"],
|
|
31
|
+
["wheel", "move the selection of the table under the pointer"]
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# What each panel shows, by panel name; panels not listed show their title only.
|
|
35
|
+
PANELS = {
|
|
36
|
+
memory: "RAM: used/total, app, wired, compressed, cached, swap, pressure and its drivers",
|
|
37
|
+
session: "agent sessions: CPU, footprint, peak, CPU seconds, disk written, age",
|
|
38
|
+
process: "every process with its session, directory, CPU, footprint, resident size, disk rates",
|
|
39
|
+
detail: "the selected process: command line, cwd, memory, CPU time, files, start time"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def open?(store) = store[:open] == true
|
|
45
|
+
|
|
46
|
+
def toggle(store) = store[:open] = !open?(store)
|
|
47
|
+
|
|
48
|
+
# The overlay text, at most `width` x `height`. `hints` are app.hint_pairs ([key, label]);
|
|
49
|
+
# agentmon's own labels win for the keys it describes itself.
|
|
50
|
+
def render(app, hints, width, height)
|
|
51
|
+
ours = KEYS.map(&:first)
|
|
52
|
+
keys = hints.map { |k, l| [k.to_s, l.to_s] }.reject { |k, _| ours.include?(k) } + MOVE + KEYS
|
|
53
|
+
pad = (keys + MOUSE).map { |k, _| k.length }.max
|
|
54
|
+
lines = ["agentmon help", "", "Keys"]
|
|
55
|
+
lines += keys.map { |k, l| " #{k.ljust(pad)} #{l}" }
|
|
56
|
+
lines += ["", "Mouse"] + MOUSE.map { |k, l| " #{k.ljust(pad)} #{l}" }
|
|
57
|
+
lines += ["", "Panels"]
|
|
58
|
+
lines += app.dashboard.panels.map do |p|
|
|
59
|
+
" #{panel_title(app, p)}#{" #{PANELS[p.name]}" if PANELS[p.name]}"
|
|
60
|
+
end
|
|
61
|
+
lines += ["", "Press any key to close."]
|
|
62
|
+
lines.first([height, 1].max).map { |line| line[0, [width, 0].max] }.join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The title the panel's border shows: its own, else its resource's, else its name.
|
|
66
|
+
def panel_title(app, panel)
|
|
67
|
+
resource = panel.resource && app.registry.resource(panel.resource)
|
|
68
|
+
(panel.title || resource&.title || panel.name.to_s.capitalize).to_s
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
dashboard do
|
|
74
|
+
mouse :cell
|
|
75
|
+
on_key("?", help: "help") { UI::MouseAndHelp.toggle(store(UI::MouseAndHelp::STORE)) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
R2UI.extension :agentmon_help do
|
|
79
|
+
# While the help is open it takes every key (closing it) and every mouse message.
|
|
80
|
+
on(->(m) { m.is_a?(Bubbletea::KeyMessage) || m.is_a?(Bubbletea::MouseMessage) }, priority: 100) do |message|
|
|
81
|
+
help = store(UI::MouseAndHelp::STORE)
|
|
82
|
+
pass unless UI::MouseAndHelp.open?(help)
|
|
83
|
+
|
|
84
|
+
help[:open] = false if message.is_a?(Bubbletea::KeyMessage)
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
view_override do
|
|
89
|
+
if dashboard.name == UI::DASHBOARD && UI::MouseAndHelp.open?(store(UI::MouseAndHelp::STORE))
|
|
90
|
+
UI::MouseAndHelp.render(app, app.hint_pairs(self), width, height)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The :memory resource and the Memory panel (top row, left): the RAM deep dive Activity Monitor
|
|
4
|
+
# shows, plus which agent sessions push memory pressure.
|
|
5
|
+
#
|
|
6
|
+
# ╭ Memory ──────────────────────────────────────╮
|
|
7
|
+
# │Used ▕██████████████░░░░▏ 12G / 16G 75% │
|
|
8
|
+
# │App 8.0G Wired 2.0G │
|
|
9
|
+
# │Compressed 2.0G Ratio 3.1x │
|
|
10
|
+
# │Cached 3.0G Swap used 512M │
|
|
11
|
+
# │Swap in 1.0M/s Swap out 256K/s │
|
|
12
|
+
# │Compress 4.0M/s Decompress 3.0M/s │
|
|
13
|
+
# │Pressure ▁▂▂▃▃▄▅▅▆▆▅▄▃▃▂▂▃▄▅▆▇▇█ 42.0% │
|
|
14
|
+
# │claude 4242 · repo 1.5G 12.5% +2.0M/s │
|
|
15
|
+
# │Claude 59334 1.0G 8.3% -512K/s │
|
|
16
|
+
# ╰──────────────────────────────────────────────╯
|
|
17
|
+
#
|
|
18
|
+
# Used = app + wired + compressed (Activity Monitor's "Memory Used"); Ratio is how much the
|
|
19
|
+
# compressor squeezes (stored bytes / bytes it occupies); the rates are bytes per second between
|
|
20
|
+
# samples. Pressure is 100 − kern.memorystatus_level, its line the last 60 samples (2 minutes).
|
|
21
|
+
# The last lines are the three sessions with the largest footprint: footprint, share of used
|
|
22
|
+
# memory, and how fast their footprint grew over the last minute.
|
|
23
|
+
#
|
|
24
|
+
# The numbers come from the `memory` metric (a MemoryView) and the `pressure_drivers` metric
|
|
25
|
+
# (PressureDrivers). While `memory` is unavailable the panel says so and shows no numbers.
|
|
26
|
+
module Agentmon
|
|
27
|
+
module UI
|
|
28
|
+
module MemoryPanel
|
|
29
|
+
# What the resource returns: one record per frame, so the gauge, stats and lines agree.
|
|
30
|
+
Record = Data.define(:view, :drivers)
|
|
31
|
+
|
|
32
|
+
# [attribute, label, r2ui format] of the MemoryView fields the resource exposes.
|
|
33
|
+
ATTRIBUTES = [
|
|
34
|
+
[:total, "Total", :bytes], [:used, "Used", :bytes], [:app, "App", :bytes], [:wired, "Wired", :bytes],
|
|
35
|
+
[:compressed, "Compressed", :bytes], [:compression_ratio, "Ratio", :ratio], [:cached, "Cached", :bytes],
|
|
36
|
+
[:free, "Free", :bytes], [:swap_used, "Swap used", :bytes], [:swap_total, "Swap total", :bytes],
|
|
37
|
+
[:swapin_rate, "Swap in", :bytes_per_sec], [:swapout_rate, "Swap out", :bytes_per_sec],
|
|
38
|
+
[:compression_rate, "Compress", :bytes_per_sec], [:decompression_rate, "Decompress", :bytes_per_sec],
|
|
39
|
+
[:pressure, "Pressure", :percent]
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# The stats grid, two per line, in this order.
|
|
43
|
+
STATS = %i[app wired compressed compression_ratio cached swap_used swapin_rate swapout_rate compression_rate
|
|
44
|
+
decompression_rate].freeze
|
|
45
|
+
|
|
46
|
+
DRIVERS = 3
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
def record(reading)
|
|
51
|
+
view = reading[:memory] or return []
|
|
52
|
+
[Record.new(view:, drivers: reading[:pressure_drivers] || [])]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def format(key, value)
|
|
56
|
+
_, _, fmt = ATTRIBUTES.find { |a| a.first == key }
|
|
57
|
+
R2UI::Format.call(fmt, value)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def label(key) = ATTRIBUTES.find { |a| a.first == key }[1]
|
|
61
|
+
|
|
62
|
+
# "App 8.0G Wired 2.0G", two cells per line (one when narrower than 40).
|
|
63
|
+
def stats(view, width)
|
|
64
|
+
cols = width >= 40 ? 2 : 1
|
|
65
|
+
cell = (width - (cols - 1)) / cols
|
|
66
|
+
STATS.map { |key| cell_text(label(key), format(key, view.public_send(key)), cell) }
|
|
67
|
+
.each_slice(cols).map { |cells| cells.join(" ") }.join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# "Pressure ▁▂▃▅▆ 42.0%": the trend scaled to 100%, newest next to the current value.
|
|
71
|
+
def pressure(view, width)
|
|
72
|
+
now = format(:pressure, view.pressure)
|
|
73
|
+
room = width - "Pressure ".length - now.length - 1
|
|
74
|
+
spark = room.positive? ? R2UI::Widgets::Sparkline.line(view.pressure_trend || [], room, max: 100) : ""
|
|
75
|
+
"Pressure #{spark.rjust([room, 0].max)} #{now}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# "claude 4242 · repo 1.5G 12.5% +2.0M/s" for the largest three; a nil share is blank.
|
|
79
|
+
def drivers(drivers, width)
|
|
80
|
+
drivers.first(DRIVERS).map do |d|
|
|
81
|
+
right = Kernel.format("%6s %6s %8s", R2UI::Format.bytes(d.footprint.to_i),
|
|
82
|
+
d.share ? Kernel.format("%.1f%%", d.share) : "", growth(d.growth_rate))
|
|
83
|
+
name = d.label.to_s
|
|
84
|
+
room = width - right.length - 1
|
|
85
|
+
name = room > 1 && name.length > room ? "#{name[0, room - 1]}…" : name
|
|
86
|
+
"#{name.ljust([room, 0].max)} #{right}"
|
|
87
|
+
end.join("\n")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def growth(rate)
|
|
91
|
+
return "" if rate.nil?
|
|
92
|
+
|
|
93
|
+
"#{rate.negative? ? "-" : "+"}#{R2UI::Format.bytes(rate.abs)}/s"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def cell_text(label, value, width) = "#{label.ljust([width - value.length, label.length + 1].max)}#{value}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
resource :memory do |engine|
|
|
101
|
+
title "Memory"
|
|
102
|
+
source { UI::MemoryPanel.record(engine.current) }
|
|
103
|
+
refresh every: engine.interval
|
|
104
|
+
|
|
105
|
+
UI::MemoryPanel::ATTRIBUTES.each do |key, label, fmt|
|
|
106
|
+
attribute(key, label:, format: fmt) { |r| r.view.public_send(key) }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
panel :memory, row: :top, order: 10, span: 1 do |_engine|
|
|
111
|
+
gauge :used, of: :total, label: "Used"
|
|
112
|
+
view do
|
|
113
|
+
record = app.feeds[:memory]&.rows&.first
|
|
114
|
+
record ? UI::MemoryPanel.stats(record.view, width) : "memory: not available"
|
|
115
|
+
end
|
|
116
|
+
view do
|
|
117
|
+
record = app.feeds[:memory]&.rows&.first
|
|
118
|
+
record ? UI::MemoryPanel.pressure(record.view, width) : ""
|
|
119
|
+
end
|
|
120
|
+
view do
|
|
121
|
+
record = app.feeds[:memory]&.rows&.first
|
|
122
|
+
record ? UI::MemoryPanel.drivers(record.drivers, width) : ""
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Terminate or kill processes from the Processes panel.
|
|
4
|
+
#
|
|
5
|
+
# K terminate: sends TERM (the process may clean up and exit)
|
|
6
|
+
# X kill: sends KILL (immediate, can't be caught)
|
|
7
|
+
#
|
|
8
|
+
# Each asks first ("terminate 1 row? y/n"; y sends, any other key cancels). On a group line
|
|
9
|
+
# (g: Session, Directory, Name, tree) it signals every process in the group: K on
|
|
10
|
+
# "claude 200 · repo" terminates the whole session. pid 0, pid 1 (launchd) and agentmon itself are
|
|
11
|
+
# refused ("kill failed: refusing to signal pid 1 (launchd)"); a process that is another user's
|
|
12
|
+
# or already gone shows "terminate failed: pid 200 (claude): Operation not permitted" on the
|
|
13
|
+
# status bar instead of stopping the dashboard.
|
|
14
|
+
module Agentmon
|
|
15
|
+
module UI
|
|
16
|
+
module ProcessActions
|
|
17
|
+
# pid 0 is the kernel, pid 1 launchd: signalling either is never what you meant.
|
|
18
|
+
PROTECTED = [0, 1].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Delivers a signal: `(signal_name, pid)`. Tests swap it to record instead of signalling.
|
|
22
|
+
attr_accessor :sender
|
|
23
|
+
|
|
24
|
+
# Sends `signal` ("TERM", "KILL") to the row's process, or raises Agentmon::Error with the
|
|
25
|
+
# reason (shown as "<action> failed: <reason>").
|
|
26
|
+
def signal(signal, row)
|
|
27
|
+
pid = row.pid
|
|
28
|
+
raise Error, "refusing to signal agentmon itself (pid #{pid})" if pid == Process.pid
|
|
29
|
+
raise Error, "refusing to signal pid #{pid.inspect} (#{row.name})" if pid.nil? || PROTECTED.include?(pid)
|
|
30
|
+
|
|
31
|
+
sender.call(signal, pid)
|
|
32
|
+
rescue Errno::EPERM, Errno::ESRCH => e
|
|
33
|
+
raise Error, "pid #{pid} (#{row.name}): #{e.message.sub(/ - .*\z/, "")}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
self.sender = ->(signal, pid) { Process.kill(signal, pid) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
extend_resource :process do |_engine|
|
|
42
|
+
action :terminate, key: "K", label: "terminate", confirm: true do |row|
|
|
43
|
+
UI::ProcessActions.signal("TERM", row)
|
|
44
|
+
end
|
|
45
|
+
action :kill, key: "X", label: "kill", confirm: true do |row|
|
|
46
|
+
UI::ProcessActions.signal("KILL", row)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|