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