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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ # visual: gauges, areas and heat, sessions first (docs/views.md). Home is the machine's breakdown
4
+ # over every agent session, by name when Claude Code or Codex knows one (Enter on one drills into
5
+ # it: its CPU gauge and area beside the machine's RAM with the session's slice, network and disk
6
+ # areas, processes beside the detail, connections; Escape returns). Its real home frame, from
7
+ # `COLUMNS=100 LINES=50 bundle exec exe/agentmon --layout visual | cat` (one sample, so areas and
8
+ # sparklines are still empty and network rates still settling):
9
+ #
10
+ # ╭─ CPU ──────────────────────────────────────────╮╭─ Memory ───────────────────────────────────────╮
11
+ # │CPU ▕███████████████▏ ▏ 41.8%││used ▕████████████████▌ ▏ 25G / 32G 79%│
12
+ # │last 4 min 41.8%││pressure ▕██████████▏ ▏ 48.0%│
13
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││swap ▕█████▎ ▏ 255M / 1.0G 25%│
14
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││app 10G wired 3.5G│
15
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││compressed 11G cached 5.7G│
16
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠││used, last 4 min 25G│
17
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸│
18
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸│
19
+ # │load 1m 5.5 load 5m 6.2 load 15m 6.0 ││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸│
20
+ # ╰────────────────────────────────────────────────╯╰────────────────────────────────────────────────╯
21
+ # ╭─ Network ──────────────────────────────────────╮╭─ Disk ─────────────────────────────────────────╮
22
+ # │net in 1.7K/s││disk read 933K/s│
23
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸│
24
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸│
25
+ # │net out 1.8K/s││disk write 0B/s│
26
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
27
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
28
+ # ╰────────────────────────────────────────────────╯╰────────────────────────────────────────────────╯
29
+ # ╭─ Sessions ─[Live] All ───────────────────────────────────────────────────────────────────────────╮
30
+ # │ Session CPU▼ Footprint Read Write Net in Net out│
31
+ # │ Claude 21354 ⠀⠀⠀⠀⢀ 23.4% 3.7G 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
32
+ # │ Agentmon r2ui integration… ⠀⠀⠀⠀⢀ 14.6% 372M 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
33
+ # │ ChatGPT 2388 ⠀⠀⠀⠀⢀ 10.0% 3.3G 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
34
+ # │ Project history · claude… ⠀⠀⠀⠀⢀ 1.0% 285M 0B/s 0B/s ⠀⠀⠀⠀⢀ │
35
+ # │ EPK component review · claude… ⠀⠀⠀⠀⢀ 0.5% 262M 0B/s 0B/s ⠀⠀⠀⠀⢀ │
36
+ # │ Mods in Desktop vs Code… ⠀⠀⠀⠀⢀ 0.3% 226M 0B/s 0B/s ⠀⠀⠀⠀⢀ │
37
+ # │ Set up a Homebrew tap for… ⠀⠀⠀⠀⢀ 0.1% 200M 0B/s 0B/s ⠀⠀⠀⠀⢀ │
38
+ # │ Weekly session themes review… ⠀⠀⠀⠀⢀ 0.1% 181M 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
39
+ # │ codex 3064 · Resources ⠀⠀⠀⠀⢀ 0.0% 76M 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
40
+ # │ Build Magrathea local POC +8… ⠀⠀⠀⠀⢀ 0.0% 1022M 0B/s 0B/s 0B/s ⠀⠀⠀⠀⢀ 0B/s│
41
+ # │ │
42
+ # ... 6 more empty table lines ...
43
+ # ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
44
+ # ╭─ All agents ───────────────────────────────────╮╭─ Agents network ───────────────────────────────╮
45
+ # │CPU 5.0%││net in 0B/s│
46
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
47
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
48
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
49
+ # │footprint 9.6G││net out 0B/s│
50
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
51
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
52
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
53
+ # ╰────────────────────────────────────────────────╯╰────────────────────────────────────────────────╯
54
+ # sessions · ⏎ open a session
55
+ #
56
+ # Drilled into "Agentmon r2ui integration" (a pty at 100×50: `/` integration, Enter). The RAM
57
+ # meter is the machine (25G of 32G used) with this session's footprint as the accent slice at the
58
+ # left, the rest of used memory dim and free memory empty:
59
+ #
60
+ # ╭─ CPU ──────────────────────────────────────────╮╭─ Memory ───────────────────────────────────────╮
61
+ # │CPU ▕ ▏ 0.1%││RAM ▕▏█████████████▉ ▏ 323M 1% · 25G/32G│
62
+ # │last 4 min 0.1%││pressure ▕█████████▏ ▏ 48.0%│
63
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││resident 439M wired 0B│
64
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││growth 0B/s pageins 0/s│
65
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││footprint, last 4 min 323M│
66
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿│
67
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿│
68
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿│
69
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿│
70
+ # ╰────────────────────────────────────────────────╯╰────────────────────────────────────────────────╯
71
+ # ╭─ Network ──────────────────────────────────────╮╭─ Disk ─────────────────────────────────────────╮
72
+ # │net in 337B/s││read 0B/s│
73
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
74
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
75
+ # │net out 647B/s││write 0B/s│
76
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
77
+ # │⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸││⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀│
78
+ # ╰────────────────────────────────────────────────╯╰────────────────────────────────────────────────╯
79
+ # ╭─ Processes ─[Agents] All Busy Heavy Writing Waiting ─╮╭─ Detail ─────────────────────────────╮
80
+ # │ Name CPU▼ Footprint Net out ││claude pid 22602 │
81
+ # │ claude ⣀⣀⣀⣀⣀ 1.0% 320M 647B/s ││Command /Users/ryan/Library… │
82
+ # │▴sleep ⠀⠀⠀⠀⣀ 0.0% 832K 0B/s ││Directory ~/The Source/agentmon │
83
+ # │▾zsh ⣀⣀⣀⣀⣀ 0.0% 2.5M 0B/s ││Session Agentmon r2ui integration… │
84
+ # │ ││Footprint 320M │
85
+ # ... 16 more lines: the Detail pairs beside empty table lines ...
86
+ # ╰──────────────────────────────────────────────────────────╯╰──────────────────────────────────────╯
87
+ # ╭─ Connections ────────────────────────────────────────────────────────────────────────────────────╮
88
+ # │ Process Remote State In Out▼ Recv Sent│
89
+ # │▴claude 22602 160.79.104.10:443 Established 337B/s 647B/s 15K 28K│
90
+ # │▾claude 22602 160.79.104.10:443 Established 0B/s 0B/s 39K 2.9M│
91
+ # │▾claude 22602 160.79.104.10:443 Established 0B/s 0B/s 918K 4.5M│
92
+ # │▾claude 22602 160.79.104.10:443 Established 0B/s 0B/s 234K 540K│
93
+ # │▾claude 22602 ….bc.googleusercontent.com:443 Established 0B/s 0B/s 26K 8.3M│
94
+ # ╰───────────────────────────────────────────────────────────────────────────────────────────── 2/6 ╯
95
+ # ▸ Agentmon r2ui integration · claude 22602 · agentmon · busy · esc back to sessions
96
+ #
97
+ # The Sessions table leaves Procs off so disk read and write both fit at 100 columns (its label,
98
+ # the session's name first, is 30 wide); footprint has no sparkline (a flat footprint draws a full
99
+ # braille column, not a baseline).
100
+ module Agentmon
101
+ view :visual, title: "agentmon" do
102
+ # Home: the machine's breakdown, then every agent session.
103
+ row height: 11 do
104
+ panel :cpu, title: "CPU" do
105
+ meter "system.cpu"
106
+ trend "system.cpu", height: 6, label: "last 4 min"
107
+ stat "system.load1", "system.load5", "system.load15", columns: 3
108
+ end
109
+ panel :memory, title: "Memory" do
110
+ meter "memory.used"
111
+ meter "memory.pressure"
112
+ meter "memory.swap_used"
113
+ stat "memory.app", "memory.wired", "memory.compressed", "memory.cached", columns: 2
114
+ trend "memory.used", height: nil, label: "used, last 4 min"
115
+ end
116
+ end
117
+ row height: 8 do
118
+ panel :network, title: "Network" do
119
+ trend "system.net_in_rate", "system.net_out_rate", height: nil
120
+ end
121
+ panel :disk, title: "Disk" do
122
+ trend "system.disk_read_rate", "system.disk_write_rate", height: nil
123
+ end
124
+ end
125
+ row do
126
+ top :session, title: "Sessions", by: :cpu, spark: %i[cpu net_out_rate],
127
+ columns: %i[label cpu footprint read_rate write_rate net_in_rate net_out_rate],
128
+ widths: { label: 30 }
129
+ end
130
+ row height: 10 do
131
+ panel :agents, title: "All agents" do
132
+ trend "focus.cpu", "focus.footprint", height: nil
133
+ end
134
+ panel :agents_net, title: "Agents network" do
135
+ trend "focus.net_in_rate", "focus.net_out_rate", height: nil
136
+ end
137
+ end
138
+
139
+ # Drilled into one session: its CPU, memory, network and disk, processes, connections.
140
+ focused do
141
+ row height: 11 do
142
+ panel :s_cpu, title: "CPU" do
143
+ meter "focus.cpu"
144
+ trend "focus.cpu", height: nil, label: "last 4 min"
145
+ stat "focus.processes", "focus.read_rate", "focus.write_rate", columns: 3
146
+ end
147
+ panel :s_memory, title: "Memory" do
148
+ meter "memory.used", part: "focus.footprint", label: "RAM" # the session's slice of the machine
149
+ meter "memory.pressure"
150
+ stat "focus.resident", "focus.wired", "focus.growth_rate", "focus.pagein_rate", columns: 2
151
+ trend "focus.footprint", height: nil, label: "footprint, last 4 min"
152
+ end
153
+ end
154
+ row height: 8 do
155
+ panel :s_net, title: "Network" do
156
+ trend "focus.net_in_rate", "focus.net_out_rate", height: nil
157
+ end
158
+ panel :s_disk, title: "Disk" do
159
+ trend "focus.read_rate", "focus.write_rate", height: nil
160
+ end
161
+ end
162
+ row do
163
+ top :process, span: 3, by: :cpu, columns: %i[name cpu footprint net_out_rate], widths: { name: 20 }
164
+ detail :process, span: 2
165
+ end
166
+ row height: 8 do
167
+ top :connection, title: "Connections", by: :out_rate, spark: false, widths: { process: 18 },
168
+ columns: %i[process remote_text state in_rate out_rate bytes_in bytes_out]
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "r2ui"
4
+
5
+ module Agentmon
6
+ module Views
7
+ # The drawing layer of the performance-view DSL (docs/views.md, "The DSL"): pure functions,
8
+ # Strings in, Strings out, no app, engine or state. Every line returned is exactly `width`
9
+ # visible cells (SGR escapes count zero, wide characters two), padded with spaces, never more.
10
+ # Colour is 24-bit SGR through R2UI's Glyphs: percents take heat, bytes the accent, unknown
11
+ # values show "–" in dim.
12
+ #
13
+ # The examples below are the plain text (escapes stripped).
14
+ #
15
+ # cut("claude 74432 · agentmon", 15) # => "claude 74432…"
16
+ # cut("bare-modifier-monitor", 16) # => "bare-modifier…"
17
+ # cut("~/src/very/long/repo", 12, from: :left) # => "…/long/repo"
18
+ # meter(label: "busy", text: "42%", fraction: 0.42, width: 20)
19
+ # # => "busy ▕███▊ ▏ 42%"
20
+ # meter(label: "busy", text: nil, fraction: nil, width: 16)
21
+ # # => "busy ▕ ▏ –"
22
+ # stacked_meter(label: "RAM", text: "1G", part: 0.25, fraction: 0.5, width: 16)
23
+ # # => "RAM ▕█▊█▌ ▏ 1G" (█▊ accent, █▌ dim)
24
+ # spark(label: "cpu", values: [1, 2, 3, 4], text: "4%", width: 12)
25
+ # # => "cpu ⠀⠀⠀⣠⣾ 4%"
26
+ # trend(label: "CPU", text: "42%", values: [1, 2, 3, 4], width: 10, height: 1)
27
+ # # => ["CPU 42%", "⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾"]
28
+ # stat(pairs: [["load1", "2.1", nil], ["load5", nil, nil]], width: 24)
29
+ # # => ["load1 2.1 load5 –"]
30
+ # tile(title: "claude 4242", body: ["cpu 12%"], width: 18, height: 3)
31
+ # # => ["╭─ claude 4242 ──╮",
32
+ # # "│cpu 12% │",
33
+ # # "╰────────────────╯"]
34
+ #
35
+ # value_sgr picks the SGR params for a value: heat for a percent with a fraction, the accent
36
+ # for bytes and byte rates, nil otherwise, dim when unknown; a pulse (0..1) mixes the colour
37
+ # toward PULSE and turns bold above 0.3.
38
+ module Widgets
39
+ G = R2UI::Widgets::Glyphs
40
+ ACCENT = "#D97757"
41
+ DIM = "#555555"
42
+ TROUGH = "#303030"
43
+ PULSE = "#FFE0C2"
44
+ PLAIN = "#BCBCBC"
45
+ MUTED = "#8A8A8A"
46
+ UNKNOWN = "–"
47
+ ELLIPSIS = "…"
48
+ SEPARATORS = [" ", "·", "-", ",", ":", "/"].freeze
49
+ BYTE_UNITS = %i[bytes bytes_per_sec signed_bytes_per_sec].freeze
50
+ SGR = /\e\[[0-9;?]*[ -\/]*[@-~]/
51
+ TOKEN = /\e\[[0-9;?]*[ -\/]*[@-~]|\X/
52
+
53
+ module_function
54
+
55
+ # Visible cells of `str`: SGR escapes are zero-width, wide characters two.
56
+ def visible_width(str)
57
+ R2UI::Compat::Tea::ANSI.string_width(str.to_s.gsub(SGR, ""))
58
+ end
59
+
60
+ # `str` padded with spaces to `width` cells (right-aligned with align: :right). Never cuts.
61
+ def pad(str, width, align: :left)
62
+ str = str.to_s
63
+ gap = width - visible_width(str)
64
+ return str if gap <= 0
65
+
66
+ align == :right ? (" " * gap) + str : str + (" " * gap)
67
+ end
68
+
69
+ # Plain `text` cut to at most `width` cells, the ellipsis at a word boundary when one exists.
70
+ def cut(text, width, from: :right)
71
+ text = text.to_s
72
+ return "" if width <= 0
73
+ return text if visible_width(text) <= width
74
+ return ELLIPSIS if width == 1
75
+
76
+ from == :left ? cut_left(text, width) : cut_right(text, width)
77
+ end
78
+
79
+ # SGR params (or nil) for a value of `unit`; see the module comment.
80
+ def value_sgr(unit:, fraction: nil, pulse: 0.0, known: true)
81
+ return G.fg(DIM) unless known
82
+
83
+ heat = unit == :percent && !fraction.nil?
84
+ bytes = BYTE_UNITS.include?(unit)
85
+ pulse = pulse.to_f.clamp(0.0, 1.0)
86
+ if pulse.positive?
87
+ base = if heat then G.gradient(G::HEAT, fraction.to_f.clamp(0.0, 1.0))
88
+ elsif bytes then ACCENT
89
+ else PLAIN
90
+ end
91
+ sgr = G.fg(R2UI::Motion.mix_hex(base, PULSE, pulse))
92
+ return pulse > 0.3 ? "#{sgr};1" : sgr
93
+ end
94
+ return G.heat(fraction) if heat
95
+ return G.fg(ACCENT) if bytes
96
+
97
+ nil
98
+ end
99
+
100
+ # One line: `label ▕bar▏ text`; without room for a 3-cell bar, label left and text right.
101
+ def meter(label:, text:, fraction:, width:, label_width: nil, text_width: nil, bar_sgr: nil, text_sgr: nil)
102
+ return "" if width <= 0
103
+
104
+ known = !fraction.nil? && !text.nil?
105
+ text, text_sgr = UNKNOWN, G.fg(DIM) unless known
106
+ label = label.to_s
107
+ text = text.to_s
108
+ label_w = label_width || visible_width(label)
109
+ text_w = text_width || visible_width(text)
110
+ bw = width - label_w - text_w - 4
111
+ return sides(label, nil, text, text_sgr, width) if bw < 3
112
+
113
+ fraction = known ? fraction.to_f.clamp(0.0, 1.0) : 0.0
114
+ bar_sgr ||= known ? G.heat(fraction) : G.fg(DIM)
115
+ meter_line(label, label_w, G.paint(G.bar(fraction, bw), "#{bar_sgr};#{G.bg(TROUGH)}"), text, text_sgr,
116
+ text_w, width)
117
+ end
118
+
119
+ # A meter whose bar stacks a part of the whole: `label ▕PPrrr ▏ text`, from the left `part`
120
+ # (0..1 of the whole) in `part_hex`, the rest of `fraction` in `rest_hex`, the trough after
121
+ # (stacked_bar). Unknown part, fraction or text draws as an unknown meter.
122
+ #
123
+ # stacked_meter(label: "RAM", text: "1G 6% · 9G/16G", part: 0.25, fraction: 0.5, width: 30)
124
+ # # => "RAM ▕██▎█▌ ▏ 1G 6% · 9G/16G" (plain)
125
+ def stacked_meter(label:, text:, part:, fraction:, width:, label_width: nil, text_width: nil,
126
+ part_hex: ACCENT, rest_hex: DIM, text_sgr: nil)
127
+ return "" if width <= 0
128
+ if part.nil? || fraction.nil? || text.nil?
129
+ return meter(label:, text: nil, fraction: nil, width:, label_width:, text_width:)
130
+ end
131
+
132
+ label = label.to_s
133
+ text = text.to_s
134
+ label_w = label_width || visible_width(label)
135
+ text_w = text_width || visible_width(text)
136
+ bw = width - label_w - text_w - 4
137
+ return sides(label, nil, text, text_sgr, width) if bw < 3
138
+
139
+ meter_line(label, label_w, stacked_bar(part, fraction, bw, part_hex:, rest_hex:), text, text_sgr, text_w,
140
+ width)
141
+ end
142
+
143
+ # `width` cells: `part` then the rest of `fraction` (both 0..1 of the whole; part is clamped to
144
+ # fraction) then the trough. Eighth-block precise: a boundary cell draws a left partial block
145
+ # (▏..▉) in the left segment's colour over the right segment's colour as its background. A
146
+ # part above zero shows at least one eighth. When both boundaries fall in one cell the part
147
+ # keeps its eighths and the cell's background is whichever of rest or trough covers more of it.
148
+ def stacked_bar(part, fraction, width, part_hex: ACCENT, rest_hex: DIM)
149
+ return "" if width <= 0
150
+
151
+ whole = fraction.to_f.clamp(0.0, 1.0)
152
+ part = part.to_f.clamp(0.0, whole)
153
+ used_e = (whole * width * 8).round
154
+ part_e = (part * width * 8).round
155
+ part_e = 1 if part_e.zero? && part.positive?
156
+ used_e = part_e if used_e < part_e
157
+ runs = []
158
+ width.times do |i|
159
+ glyph, fg, bg = stacked_cell((part_e - (8 * i)).clamp(0, 8), (used_e - (8 * i)).clamp(0, 8),
160
+ part_hex, rest_hex)
161
+ sgr = [fg && G.fg(fg), G.bg(bg)].compact.join(";")
162
+ if runs.last && runs.last[1] == sgr
163
+ runs.last[0] << glyph
164
+ else
165
+ runs << [+glyph, sgr]
166
+ end
167
+ end
168
+ runs.map { |glyphs, sgr| G.paint(glyphs, sgr) }.join
169
+ end
170
+
171
+ # One stacked_bar cell with `a` eighths of part and `b` eighths of part + rest: [glyph, fg, bg].
172
+ def stacked_cell(a, b, part_hex, rest_hex)
173
+ if a == 8 then ["█", part_hex, TROUGH]
174
+ elsif a.positive? then [G::PARTIAL[a - 1], part_hex, b - a > 8 - b ? rest_hex : TROUGH]
175
+ elsif b == 8 then ["█", rest_hex, TROUGH]
176
+ elsif b.positive? then [G::PARTIAL[b - 1], rest_hex, TROUGH]
177
+ else [" ", nil, TROUGH]
178
+ end
179
+ end
180
+
181
+ # `label ▕bar▏ text` with the label and text padded to their widths, fitted to `width`.
182
+ def meter_line(label, label_w, bar, text, text_sgr, text_w, width)
183
+ line = pad(cut(label, label_w), label_w) + " " + G.paint("▕", G.fg(DIM)) + bar + G.paint("▏", G.fg(DIM)) +
184
+ " " + pad(G.paint(text, text_sgr), text_w, align: :right)
185
+ fit(line, width)
186
+ end
187
+
188
+ # One line: `label ⣀⣠⣴⣾ text` (braille sparkline, newest at the right).
189
+ def spark(label:, values:, text:, width:, max: nil, label_width: nil, text_width: nil, chart_sgr: nil, text_sgr: nil)
190
+ return "" if width <= 0
191
+
192
+ text, text_sgr = UNKNOWN, G.fg(DIM) if text.nil?
193
+ label = label.to_s
194
+ text = text.to_s
195
+ label_w = label_width || visible_width(label)
196
+ text_w = text_width || visible_width(text)
197
+ sw = width - label_w - text_w - 2
198
+ return sides(label, nil, text, text_sgr, width) if sw < 2
199
+
200
+ values = Array(values)
201
+ values = Array.new(sw * 2, 0) if values.size < 2
202
+ chart = G.braille_line(values, sw, max: max)
203
+ line = pad(cut(label, label_w), label_w) + " " + G.paint(chart, chart_sgr || G.fg(ACCENT)) +
204
+ " " + pad(G.paint(text, text_sgr), text_w, align: :right)
205
+ fit(line, width)
206
+ end
207
+
208
+ # height + 1 lines: a header (label left, text right) then a braille area chart.
209
+ def trend(label:, text:, values:, width:, height:, max: nil, chart_sgr: nil, text_sgr: nil)
210
+ return [] if width <= 0
211
+
212
+ text, text_sgr = UNKNOWN, G.fg(DIM) if text.nil?
213
+ header = sides(label.to_s, G.fg(MUTED), text.to_s, text_sgr, width)
214
+ return [header] if height <= 0
215
+
216
+ area = G.braille_area(Array(values), width, height, max: max)
217
+ [header] + area.map { |row| fit(G.paint(row, chart_sgr || G.fg(ACCENT)), width) }
218
+ end
219
+
220
+ # A label/value grid, `columns` cells per line; pairs are [label, text_or_nil, sgr_or_nil].
221
+ def stat(pairs:, width:, columns: 2, gap: 2)
222
+ return [] if width <= 0 || pairs.nil? || pairs.empty?
223
+
224
+ columns = [columns.to_i, 1].max
225
+ gap = [gap.to_i, 0].max
226
+ cell_w = (width - (gap * (columns - 1))) / columns
227
+ pairs.each_slice(columns).map do |row|
228
+ next " " * width if cell_w <= 0
229
+
230
+ cells = row.map do |label, text, sgr|
231
+ text, sgr = UNKNOWN, G.fg(DIM) if text.nil?
232
+ sides(label.to_s, G.fg(MUTED), text.to_s, sgr, cell_w)
233
+ end
234
+ fit(cells.join(" " * gap), width)
235
+ end
236
+ end
237
+
238
+ # Exactly `height` lines: a rounded border with `title` in the top edge around `body`.
239
+ def tile(title:, body:, width:, height:, border_sgr: nil, title_sgr: nil)
240
+ return [] if height < 2
241
+ return Array.new(height, " " * [width, 0].max) if width < 4
242
+
243
+ inner = width - 2
244
+ room = inner - 3
245
+ title = room.positive? ? cut(title.to_s, room) : ""
246
+ top = if title.empty?
247
+ G.paint("╭#{"─" * inner}╮", border_sgr)
248
+ else
249
+ G.paint("╭─ ", border_sgr) + G.paint(title, title_sgr) +
250
+ G.paint(" #{"─" * (room - visible_width(title))}╮", border_sgr)
251
+ end
252
+ body = Array(body)
253
+ rows = Array.new(height - 2) do |i|
254
+ G.paint("│", border_sgr) + fit(body[i].to_s, inner) + G.paint("│", border_sgr)
255
+ end
256
+ [top, *rows, G.paint("╰#{"─" * inner}╯", border_sgr)]
257
+ end
258
+
259
+ # ANSI-aware: the first `width` visible cells of `str`, SGR runs kept, re-closed with a reset.
260
+ def truncate(str, width)
261
+ str = str.to_s
262
+ return str if visible_width(str) <= width
263
+
264
+ out = +""
265
+ used = 0
266
+ styled = false
267
+ str.scan(TOKEN) do |tok|
268
+ if tok.start_with?("\e[")
269
+ out << tok
270
+ styled = true
271
+ next
272
+ end
273
+ w = R2UI::Compat::Tea::ANSI.string_width(tok)
274
+ break if used + w > width
275
+
276
+ out << tok
277
+ used += w
278
+ end
279
+ styled ? out << "\e[0m" : out
280
+ end
281
+
282
+ # `str` truncated then padded to exactly `width` cells.
283
+ def fit(str, width)
284
+ return "" if width <= 0
285
+
286
+ pad(truncate(str, width), width)
287
+ end
288
+
289
+ # `left` and `right` on one `width`-cell line; the right side always shows (cut only when it
290
+ # alone is wider than the line), the left is cut by the word rule to fit beside it.
291
+ def sides(left, left_sgr, right, right_sgr, width)
292
+ return "" if width <= 0
293
+
294
+ right = cut(right, width)
295
+ rw = visible_width(right)
296
+ left = cut(left, width - rw - 1)
297
+ lw = visible_width(left)
298
+ gap = width - lw - rw
299
+ fit(G.paint(left, left.empty? ? nil : left_sgr) + (" " * gap) + G.paint(right, right_sgr), width)
300
+ end
301
+
302
+ def cut_right(text, width)
303
+ best = nil
304
+ text.each_char.with_index do |ch, i|
305
+ next unless SEPARATORS.include?(ch) # "bare-modifier-monitor" → "bare-modifier…"
306
+
307
+ prefix = strip_tail(text[0, i])
308
+ best = prefix if !prefix.empty? && visible_width(prefix) + 1 <= width
309
+ end
310
+ (best || take(text, width - 1)) + ELLIPSIS
311
+ end
312
+
313
+ def cut_left(text, width)
314
+ best = nil
315
+ text.each_char.with_index do |ch, i|
316
+ next unless ch == "/" || ch == " "
317
+
318
+ suffix = ch == "/" ? text[i..] : strip_head(text[i..])
319
+ next if suffix.empty? || visible_width(suffix) + 1 > width
320
+
321
+ best = suffix
322
+ break
323
+ end
324
+ ELLIPSIS + (best || take(text.reverse, width - 1).reverse)
325
+ end
326
+
327
+ def strip_tail(str)
328
+ str = str.chop while !str.empty? && SEPARATORS.include?(str[-1])
329
+ str
330
+ end
331
+
332
+ def strip_head(str)
333
+ str = str[1..] while !str.empty? && SEPARATORS.include?(str[0])
334
+ str
335
+ end
336
+
337
+ # The longest leading run of grapheme clusters of plain `str` within `width` cells.
338
+ def take(str, width)
339
+ out = +""
340
+ used = 0
341
+ str.each_grapheme_cluster do |g|
342
+ w = R2UI::Compat::Tea::ANSI.string_width(g)
343
+ break if used + w > width
344
+
345
+ out << g
346
+ used += w
347
+ end
348
+ out
349
+ end
350
+ end
351
+ end
352
+ end