brainiac 0.0.1

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 (60) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +2 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +126 -0
  6. data/README.md +1166 -0
  7. data/Rakefile +12 -0
  8. data/bin/brainiac +1521 -0
  9. data/brainiac.gemspec +30 -0
  10. data/certs/stowzilla.pem +26 -0
  11. data/docs/waybar-config.md +96 -0
  12. data/lib/brainiac/agents.rb +203 -0
  13. data/lib/brainiac/brain.rb +197 -0
  14. data/lib/brainiac/card_index.rb +389 -0
  15. data/lib/brainiac/config.rb +263 -0
  16. data/lib/brainiac/cron.rb +629 -0
  17. data/lib/brainiac/deployments.rb +258 -0
  18. data/lib/brainiac/handlers/discord.rb +1643 -0
  19. data/lib/brainiac/handlers/fizzy.rb +1249 -0
  20. data/lib/brainiac/handlers/github.rb +598 -0
  21. data/lib/brainiac/handlers/zoho.rb +487 -0
  22. data/lib/brainiac/helpers.rb +760 -0
  23. data/lib/brainiac/planning.rb +237 -0
  24. data/lib/brainiac/prompts.rb +620 -0
  25. data/lib/brainiac/sessions.rb +282 -0
  26. data/lib/brainiac/skills.rb +276 -0
  27. data/lib/brainiac/users.rb +76 -0
  28. data/lib/brainiac/version.rb +6 -0
  29. data/lib/brainiac/zoho_mail_api.rb +109 -0
  30. data/lib/brainiac.rb +10 -0
  31. data/lib/user_registry.rb +159 -0
  32. data/monitor/daemon.rb +99 -0
  33. data/monitor/deploy-env-macos.rb +131 -0
  34. data/monitor/menubar.rb +295 -0
  35. data/monitor/open-action.sh +15 -0
  36. data/monitor/setup-menubar.rb +78 -0
  37. data/monitor/setup-waybar-deploy-envs.rb +121 -0
  38. data/monitor/setup-waybar-deployments.rb +96 -0
  39. data/monitor/setup-waybar-module.rb +113 -0
  40. data/monitor/setup-xbar-plugin.rb +35 -0
  41. data/monitor/view-logs-macos.rb +210 -0
  42. data/monitor/view-logs-rofi.rb +194 -0
  43. data/monitor/view-logs.rb +119 -0
  44. data/monitor/waybar-config-updater.rb +56 -0
  45. data/monitor/waybar-deploy-env.rb +206 -0
  46. data/monitor/waybar-deployments.rb +239 -0
  47. data/monitor/waybar.rb +146 -0
  48. data/monitor/xbar.3s.rb +179 -0
  49. data/receiver.rb +956 -0
  50. data/templates/agents.json.example +10 -0
  51. data/templates/discord.json.example +17 -0
  52. data/templates/fizzy.json.example +24 -0
  53. data/templates/github.json.example +4 -0
  54. data/templates/testflight.json.example +8 -0
  55. data/templates/users.json.example +121 -0
  56. data/templates/zoho.json.example +27 -0
  57. data/views/dashboard.erb +437 -0
  58. data.tar.gz.sig +0 -0
  59. metadata +235 -0
  60. metadata.gz.sig +0 -0
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # One-time setup script to add Brainiac module to waybar config
5
+ # Run this once, then the module will update dynamically without config rewrites
6
+
7
+ require "json"
8
+ require "fileutils"
9
+
10
+ WAYBAR_CONFIG = File.expand_path("~/.config/waybar/config.jsonc")
11
+ WAYBAR_SCRIPT = File.expand_path("~/.brainiac/bin/waybar-status")
12
+
13
+ # Create a wrapper script that resolves the running server's waybar.rb dynamically
14
+ wrapper_dir = File.expand_path("~/.brainiac/bin")
15
+ FileUtils.mkdir_p(wrapper_dir)
16
+ wrapper_path = File.join(wrapper_dir, "waybar-status")
17
+ File.write(wrapper_path, <<~SCRIPT)
18
+ #!/usr/bin/env ruby
19
+ # Resolves the running Brainiac server's waybar module dynamically.
20
+ # This allows worktrees / branches to work without reconfiguring waybar.
21
+
22
+ root_file = File.expand_path("~/.brainiac/server.root")
23
+ if File.exist?(root_file)
24
+ server_root = File.read(root_file).strip
25
+ waybar_script = File.join(server_root, "monitor", "waybar.rb")
26
+ if File.exist?(waybar_script)
27
+ load waybar_script
28
+ exit
29
+ end
30
+ end
31
+
32
+ # Fallback: no server root known, try the API directly
33
+ require "json"
34
+ require "net/http"
35
+
36
+ begin
37
+ uri = URI("http://localhost:4567/api/status")
38
+ response = Net::HTTP.get_response(uri)
39
+ if response.is_a?(Net::HTTPSuccess)
40
+ data = JSON.parse(response.body)
41
+ sessions = data["sessions"] || []
42
+ if sessions.empty?
43
+ puts({ text: "💤", tooltip: "No active agent sessions", class: "idle" }.to_json)
44
+ else
45
+ puts({ text: "🟢 \#{sessions.size}", tooltip: sessions.map { |s| s["agent"] }.join(", "), class: "working" }.to_json)
46
+ end
47
+ else
48
+ puts({ text: "⚠️", tooltip: "Brainiac Error: HTTP \#{response.code}", class: "error" }.to_json)
49
+ end
50
+ rescue StandardError => e
51
+ puts({ text: "⚠️", tooltip: "Brainiac Error: \#{e.message}", class: "error" }.to_json)
52
+ end
53
+ SCRIPT
54
+ File.chmod(0o755, wrapper_path)
55
+
56
+ def load_config
57
+ content = File.read(WAYBAR_CONFIG)
58
+ # Strip comments for JSON parsing
59
+ json_content = content.lines.reject { |line| line.strip.start_with?("//") }.join
60
+ JSON.parse(json_content)
61
+ end
62
+
63
+ def save_config(config)
64
+ File.write(WAYBAR_CONFIG, JSON.pretty_generate(config))
65
+ end
66
+
67
+ # Load current config
68
+ config = load_config
69
+
70
+ # Remove old brainiac modules if they exist (from all module arrays)
71
+ %w[modules-left modules-center modules-right].each do |section|
72
+ next unless config[section].is_a?(Array)
73
+
74
+ config[section].reject! { |m| ["custom/brainiac", "group/brainiac-agents"].include?(m.to_s) }
75
+ end
76
+ config.each_key do |key|
77
+ config.delete(key) if ["custom/brainiac", "group/brainiac-agents"].include?(key.to_s)
78
+ end
79
+
80
+ # Add single dynamic module at the end of modules-center (after deploy envs)
81
+ config["modules-center"] ||= []
82
+ config["modules-center"].push("custom/brainiac")
83
+
84
+ # Add module config
85
+ config["custom/brainiac"] = {
86
+ "exec" => WAYBAR_SCRIPT,
87
+ "return-type" => "json",
88
+ "interval" => 3,
89
+ "format" => "{}",
90
+ "tooltip" => true,
91
+ "on-click" => File.expand_path("~/.brainiac/bin/waybar-logs").to_s
92
+ }
93
+
94
+ # Create on-click wrapper too
95
+ logs_wrapper = File.expand_path("~/.brainiac/bin/waybar-logs")
96
+ File.write(logs_wrapper, <<~SCRIPT)
97
+ #!/usr/bin/env ruby
98
+ root_file = File.expand_path("~/.brainiac/server.root")
99
+ if File.exist?(root_file)
100
+ server_root = File.read(root_file).strip
101
+ script = File.join(server_root, "monitor", "view-logs-rofi.rb")
102
+ exec("ruby", script) if File.exist?(script)
103
+ end
104
+ warn "Brainiac server root not found"
105
+ SCRIPT
106
+ File.chmod(0o755, logs_wrapper)
107
+
108
+ # Save updated config
109
+ save_config(config)
110
+
111
+ puts "✓ Brainiac module added to waybar config"
112
+ puts " Module will update every 3 seconds without config rewrites"
113
+ puts " Restart waybar to apply: omarchy restart waybar"
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # One-time setup: symlinks the Brainiac xbar plugin into xbar's plugin directory
5
+ # Run this once on macOS after installing xbar
6
+
7
+ require "fileutils"
8
+
9
+ XBAR_PLUGIN_DIR = File.expand_path("~/Library/Application Support/xbar/plugins")
10
+ PLUGIN_SOURCE = File.expand_path("xbar.3s.rb", __dir__)
11
+ PLUGIN_DEST = File.join(XBAR_PLUGIN_DIR, "brainiac.3s.rb")
12
+
13
+ unless RUBY_PLATFORM.match?(/darwin/i)
14
+ puts "⚠ This script is for macOS only (xbar doesn't run on Linux)"
15
+ exit 1
16
+ end
17
+
18
+ unless File.directory?(XBAR_PLUGIN_DIR)
19
+ puts "⚠ xbar plugin directory not found: #{XBAR_PLUGIN_DIR}"
20
+ puts " Install xbar first: https://xbarapp.com"
21
+ exit 1
22
+ end
23
+
24
+ if File.exist?(PLUGIN_DEST)
25
+ puts "Removing existing plugin at #{PLUGIN_DEST}"
26
+ File.delete(PLUGIN_DEST)
27
+ end
28
+
29
+ File.symlink(PLUGIN_SOURCE, PLUGIN_DEST)
30
+ File.chmod(0o755, PLUGIN_SOURCE)
31
+
32
+ puts "✓ Brainiac xbar plugin installed"
33
+ puts " #{PLUGIN_SOURCE} → #{PLUGIN_DEST}"
34
+ puts " Refresh interval: 3 seconds"
35
+ puts " Restart xbar to activate"
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Brainiac macOS Log Viewer
5
+ # Opens agent log files in a terminal (wezterm by default, configurable via waybar.json)
6
+ # Set "log_viewer_command" in ~/.brainiac/waybar.json to override (supports wezterm/iTerm/Terminal.app)
7
+ # Uses macOS notifications via osascript for status messages
8
+
9
+ require "json"
10
+ require "socket"
11
+
12
+ SOCKET_PATH = "/tmp/brainiac-monitor.sock"
13
+ CONFIG_PATH = File.expand_path("~/.brainiac/waybar.json")
14
+
15
+ # Load agent configuration from JSON
16
+ # Returns [agents_hash, default_emoji] tuple
17
+ def load_agent_config
18
+ config = JSON.parse(File.read(CONFIG_PATH))
19
+ agents = {}
20
+ config["agents"].each do |agent|
21
+ agents[agent["name"].downcase] = agent["emoji"]
22
+ end
23
+ default_emoji = config["default_emoji"] || "❓"
24
+ [agents, default_emoji]
25
+ rescue StandardError => e
26
+ warn "Failed to load waybar.json: #{e.message}"
27
+ [{}, "❓"]
28
+ end
29
+
30
+ AGENTS, DEFAULT_EMOJI = load_agent_config
31
+
32
+ # Read agent state from daemon socket
33
+ # Returns hash with sessions/count/last_update, or error hash if daemon unavailable
34
+ def fetch_state
35
+ socket = UNIXSocket.new(SOCKET_PATH)
36
+ data = socket.read
37
+ socket.close
38
+ JSON.parse(data)
39
+ rescue Errno::ENOENT
40
+ { "sessions" => [], "count" => 0, "error" => "daemon not running" }
41
+ rescue StandardError => e
42
+ { "sessions" => [], "count" => 0, "error" => e.message }
43
+ end
44
+
45
+ def format_elapsed(seconds)
46
+ return "#{seconds}s" if seconds < 60
47
+
48
+ minutes = seconds / 60
49
+ return "#{minutes}m" if minutes < 60
50
+
51
+ hours = minutes / 60
52
+ "#{hours}h"
53
+ end
54
+
55
+ # Send a macOS notification via osascript
56
+ # Silently fails if osascript is unavailable
57
+ def notify(title, message)
58
+ escaped_title = title.gsub('"', '\\"')
59
+ escaped_message = message.gsub('"', '\\"')
60
+ system("osascript", "-e", "display notification \"#{escaped_message}\" with title \"#{escaped_title}\"")
61
+ rescue StandardError => e
62
+ warn "Notification failed: #{e.message}"
63
+ end
64
+
65
+ def format_context(card_key)
66
+ if card_key.start_with?("discord-")
67
+ "Discord chat"
68
+ elsif card_key.start_with?("card-")
69
+ card_key.split("-")[1]
70
+ else
71
+ card_key
72
+ end
73
+ end
74
+
75
+ def load_log_viewer_command
76
+ config = JSON.parse(File.read(CONFIG_PATH))
77
+ config["log_viewer_command"]
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ DEFAULT_LOG_VIEWER = "/opt/homebrew/bin/wezterm"
83
+ LOG_VIEWER_COMMAND = load_log_viewer_command || DEFAULT_LOG_VIEWER
84
+
85
+ # Find an existing wezterm pane that's already tailing the given log file
86
+ def find_wezterm_pane_for(log_file)
87
+ json = `#{LOG_VIEWER_COMMAND} cli list --format json 2>/dev/null`
88
+ panes = JSON.parse(json)
89
+ panes.find { |p| p["title"]&.include?(log_file) || p["cwd"]&.include?(log_file) }
90
+ rescue StandardError
91
+ nil
92
+ end
93
+
94
+ # Get the window ID of the first wezterm window
95
+ def find_wezterm_window_id
96
+ json = `#{LOG_VIEWER_COMMAND} cli list --format json 2>/dev/null`
97
+ panes = JSON.parse(json)
98
+ panes.first&.dig("window_id")
99
+ rescue StandardError
100
+ nil
101
+ end
102
+
103
+ def open_log(log_file)
104
+ escaped_path = log_file.gsub("'", "'\\\\''")
105
+
106
+ if LOG_VIEWER_COMMAND.include?("wezterm")
107
+ wezterm_running = system("pgrep -qf WezTerm")
108
+ if wezterm_running
109
+ # Check if this log is already being tailed in an existing tab
110
+ existing_pane = find_wezterm_pane_for(log_file)
111
+ if existing_pane
112
+ system(LOG_VIEWER_COMMAND, "cli", "activate-pane", "--pane-id", existing_pane["pane_id"].to_s)
113
+ else
114
+ # Spawn as a new tab in the first available window
115
+ window_id = find_wezterm_window_id
116
+ args = [LOG_VIEWER_COMMAND, "cli", "spawn"]
117
+ args += ["--window-id", window_id.to_s] if window_id
118
+ args += ["--", "tail", "-f", log_file]
119
+ system(*args)
120
+ end
121
+ else
122
+ system(LOG_VIEWER_COMMAND, "start", "--", "tail", "-f", log_file)
123
+ sleep 0.5 # Give wezterm time to launch before trying to activate
124
+ end
125
+ system("open", "-a", "WezTerm")
126
+ system("osascript", "-e", 'tell application "System Events" to set frontmost of process "WezTerm" to true')
127
+ elsif LOG_VIEWER_COMMAND.include?("iTerm")
128
+ script = <<~APPLESCRIPT
129
+ tell application "iTerm"
130
+ activate
131
+ create window with default profile command "tail -f '#{escaped_path}'"
132
+ end tell
133
+ APPLESCRIPT
134
+ system("osascript", "-e", script)
135
+ elsif LOG_VIEWER_COMMAND.include?("Terminal")
136
+ system("osascript", "-e", "tell application \"Terminal\" to do script \"tail -f '#{escaped_path}'\"")
137
+ system("osascript", "-e", 'tell application "Terminal" to activate')
138
+ else
139
+ system(LOG_VIEWER_COMMAND, "tail", "-f", log_file)
140
+ end
141
+ end
142
+
143
+ # --- Main invocation logic ---
144
+
145
+ # Mode 1: xbar submenu click — log file path passed as param1 (ARGV[0])
146
+ if ARGV[0]
147
+ log_file = ARGV[0]
148
+ unless File.exist?(log_file)
149
+ notify("Brainiac", "Log file not found: #{log_file}")
150
+ exit 1
151
+ end
152
+ open_log(log_file)
153
+ exit 0
154
+ end
155
+
156
+ # Mode 2+: standalone invocation — fetch state from daemon
157
+ state = fetch_state
158
+ sessions = state["sessions"] || []
159
+
160
+ if state["error"]
161
+ notify("Brainiac", state["error"])
162
+ exit 1
163
+ end
164
+
165
+ if sessions.empty?
166
+ notify("Brainiac", "No active agent sessions")
167
+ exit 0
168
+ end
169
+
170
+ # Single session — open directly
171
+ if sessions.size == 1
172
+ log_file = sessions[0]["log_file"]
173
+ if log_file && File.exist?(log_file)
174
+ open_log(log_file)
175
+ else
176
+ notify("Brainiac", "Log file not found: #{log_file}")
177
+ end
178
+ exit 0
179
+ end
180
+
181
+ # Multiple sessions — use fzf if available
182
+ if system("which fzf > /dev/null 2>&1")
183
+ options = sessions.map do |s|
184
+ agent = s["agent"]
185
+ elapsed = format_elapsed(s["elapsed_seconds"])
186
+ context = format_context(s["card_key"])
187
+ emoji = AGENTS[agent.downcase] || DEFAULT_EMOJI
188
+ "#{emoji} #{agent}: #{context} (#{elapsed})|#{s["log_file"]}"
189
+ end
190
+
191
+ menu_text = options.join("\n")
192
+ selected = `echo "#{menu_text}" | fzf --prompt="Agent Logs: "`.strip
193
+
194
+ unless selected.empty?
195
+ log_file = selected.split("|").last
196
+ if File.exist?(log_file)
197
+ open_log(log_file)
198
+ else
199
+ notify("Brainiac", "Log file not found: #{log_file}")
200
+ end
201
+ end
202
+ else
203
+ # No fzf — open first session's log
204
+ log_file = sessions[0]["log_file"]
205
+ if log_file && File.exist?(log_file)
206
+ open_log(log_file)
207
+ else
208
+ notify("Brainiac", "Log file not found: #{log_file}")
209
+ end
210
+ end
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Brainiac Log Viewer (Rofi version)
5
+ # Shows a rofi menu to select which agent log to tail
6
+ # Non-blocking, safe for waybar on-click
7
+
8
+ require "json"
9
+ require "net/http"
10
+ require "socket"
11
+
12
+ SOCKET_PATH = "/tmp/brainiac-monitor.sock"
13
+ API_URL = "http://localhost:4567/api/status"
14
+ CONFIG_PATH = File.expand_path("~/.brainiac/waybar.json")
15
+
16
+ # Load agent configuration from JSON
17
+ def load_agent_config
18
+ config = JSON.parse(File.read(CONFIG_PATH))
19
+ agents = {}
20
+ config["agents"].each do |agent|
21
+ agents[agent["name"].downcase] = agent["emoji"]
22
+ end
23
+ default_emoji = config["default_emoji"] || "❓"
24
+ [agents, default_emoji]
25
+ rescue StandardError => e
26
+ warn "Failed to load waybar.json: #{e.message}"
27
+ [{}, "❓"]
28
+ end
29
+
30
+ AGENTS, DEFAULT_EMOJI = load_agent_config
31
+
32
+ def fetch_state_from_socket
33
+ socket = UNIXSocket.new(SOCKET_PATH)
34
+ data = socket.read
35
+ socket.close
36
+ JSON.parse(data)
37
+ end
38
+
39
+ def fetch_state_from_api
40
+ uri = URI(API_URL)
41
+ response = Net::HTTP.get_response(uri)
42
+ return nil unless response.is_a?(Net::HTTPSuccess)
43
+
44
+ JSON.parse(response.body)
45
+ end
46
+
47
+ def fetch_state
48
+ # Prefer socket (daemon mode) — faster, no HTTP overhead
49
+ fetch_state_from_socket
50
+ rescue Errno::ENOENT, Errno::ECONNREFUSED
51
+ # Daemon not running — fall back to direct API call
52
+ data = fetch_state_from_api
53
+ unless data
54
+ system("notify-send", "Brainiac", "Server not reachable")
55
+ exit 1
56
+ end
57
+ data
58
+ rescue StandardError => e
59
+ system("notify-send", "Brainiac Error", e.message)
60
+ exit 1
61
+ end
62
+
63
+ def format_elapsed(seconds)
64
+ return "#{seconds}s" if seconds < 60
65
+
66
+ minutes = seconds / 60
67
+ return "#{minutes}m" if minutes < 60
68
+
69
+ hours = minutes / 60
70
+ "#{hours}h"
71
+ end
72
+
73
+ state = fetch_state
74
+ sessions = state["sessions"] || []
75
+
76
+ if sessions.empty?
77
+ system("notify-send", "Brainiac", "No active agent sessions")
78
+ exit 0
79
+ end
80
+
81
+ INFRA_CMDS = %w[kiro-cli-chat ruby-lsp clangd gopls].freeze
82
+
83
+ # Build menu entries: sessions + their child processes
84
+ entries = []
85
+ sessions.each do |s|
86
+ agent = s["agent"]
87
+ elapsed = format_elapsed(s["elapsed_seconds"])
88
+ card_key = s["card_key"]
89
+ context = if card_key.start_with?("discord-")
90
+ "Discord chat"
91
+ elsif card_key.start_with?("card-")
92
+ card_key.split("-")[1]
93
+ else
94
+ card_key
95
+ end
96
+ emoji = AGENTS[agent.downcase] || DEFAULT_EMOJI
97
+ entries << { display: "#{emoji} #{agent}: #{context} (#{elapsed})", type: :log, log: s["log_file"] }
98
+ entries << { display: " ⛔ Kill session: #{agent} (#{context})", type: :kill_session, card_key: card_key, agent: agent }
99
+
100
+ (s["children"] || []).each do |c|
101
+ cmd_short = c["cmd"].to_s.split("/").last.to_s.split.first.to_s
102
+ cmd_short = c["cmd"].to_s[0..40] if cmd_short.empty?
103
+ next if INFRA_CMDS.any? { |ic| cmd_short.start_with?(ic) }
104
+
105
+ entries << {
106
+ display: " └ 🔪 Kill: #{cmd_short} (#{format_elapsed(c["elapsed_seconds"])}) [PID #{c["pid"]}]",
107
+ type: :kill_child, pid: c["pid"], cmd: cmd_short
108
+ }
109
+ end
110
+ end
111
+
112
+ # If only one session with no children, open log directly
113
+ if entries.size == 1 && entries[0][:type] == :log
114
+ spawn("alacritty", "-e", "tail", "-f", entries[0][:log]) if entries[0][:log]
115
+ exit 0
116
+ end
117
+
118
+ def find_launcher
119
+ %w[rofi fuzzel wofi zenity].find { |cmd| system("which #{cmd} > /dev/null 2>&1") }
120
+ end
121
+
122
+ def run_menu(launcher, entries)
123
+ menu_text = entries.map { |e| e[:display] }.join("\n")
124
+ case launcher
125
+ when "rofi"
126
+ IO.popen(%w[rofi -dmenu -i -p] + ["Agent Sessions"], "r+") do |io|
127
+ io.puts menu_text
128
+ io.close_write
129
+ io.read.strip
130
+ end
131
+ when "fuzzel"
132
+ IO.popen(%w[fuzzel --dmenu --prompt] + ["Agent Sessions: "], "r+") do |io|
133
+ io.puts menu_text
134
+ io.close_write
135
+ io.read.strip
136
+ end
137
+ when "wofi"
138
+ IO.popen(%w[wofi --dmenu --prompt] + ["Agent Sessions"], "r+") do |io|
139
+ io.puts menu_text
140
+ io.close_write
141
+ io.read.strip
142
+ end
143
+ when "zenity"
144
+ IO.popen(["zenity", "--list", "--title", "Agent Sessions", "--column", "Session", "--width", "600", "--height", "400"], "r+",
145
+ err: "/dev/null") do |io|
146
+ entries.each { |e| io.puts e[:display] }
147
+ io.close_write
148
+ io.read.strip
149
+ end
150
+ end
151
+ end
152
+
153
+ launcher = find_launcher
154
+ unless launcher
155
+ system("notify-send", "Brainiac", "No menu launcher found (install rofi, fuzzel, wofi, or zenity)")
156
+ exit 1
157
+ end
158
+
159
+ selected_line = run_menu(launcher, entries)
160
+
161
+ unless selected_line.to_s.empty?
162
+ selected = entries.find { |e| e[:display].strip == selected_line.strip }
163
+ if selected
164
+ case selected[:type]
165
+ when :log
166
+ spawn("alacritty", "-e", "tail", "-f", selected[:log]) if selected[:log]
167
+ when :kill_session
168
+ card_key = selected[:card_key]
169
+ uri = URI("http://localhost:4567/api/sessions/kill/#{card_key}")
170
+ response = Net::HTTP.post(uri, "", { "Content-Type" => "application/json" })
171
+ if response.is_a?(Net::HTTPSuccess)
172
+ system("notify-send", "Brainiac", "Killed session: #{selected[:agent]}")
173
+ else
174
+ system("notify-send", "Brainiac", "Failed to kill session: #{selected[:agent]}")
175
+ end
176
+ when :kill_child
177
+ pid = selected[:pid]
178
+ begin
179
+ Process.kill("TERM", pid)
180
+ rescue StandardError
181
+ nil
182
+ end
183
+ Thread.new do
184
+ sleep 3
185
+ begin
186
+ Process.kill("KILL", pid)
187
+ rescue StandardError
188
+ nil
189
+ end
190
+ end
191
+ system("notify-send", "Brainiac", "Killed #{selected[:cmd]} (PID #{pid})")
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Brainiac Log Viewer
5
+ # Shows a rofi menu to select which agent log to tail
6
+
7
+ require "json"
8
+ require "net/http"
9
+ require "socket"
10
+
11
+ SOCKET_PATH = "/tmp/brainiac-monitor.sock"
12
+ API_URL = "http://localhost:4567/api/status"
13
+ CONFIG_PATH = File.expand_path("~/.brainiac/waybar.json")
14
+
15
+ # Load agent configuration from JSON
16
+ def load_agent_config
17
+ config = JSON.parse(File.read(CONFIG_PATH))
18
+ agents = {}
19
+ config["agents"].each do |agent|
20
+ agents[agent["name"].downcase] = agent["emoji"]
21
+ end
22
+ default_emoji = config["default_emoji"] || "❓"
23
+ [agents, default_emoji]
24
+ rescue StandardError => e
25
+ warn "Failed to load waybar.json: #{e.message}"
26
+ [{}, "❓"]
27
+ end
28
+
29
+ AGENTS, DEFAULT_EMOJI = load_agent_config
30
+
31
+ def fetch_state_from_socket
32
+ socket = UNIXSocket.new(SOCKET_PATH)
33
+ data = socket.read
34
+ socket.close
35
+ JSON.parse(data)
36
+ end
37
+
38
+ def fetch_state_from_api
39
+ uri = URI(API_URL)
40
+ response = Net::HTTP.get_response(uri)
41
+ return nil unless response.is_a?(Net::HTTPSuccess)
42
+
43
+ JSON.parse(response.body)
44
+ end
45
+
46
+ def fetch_state
47
+ # Prefer socket (daemon mode) — faster, no HTTP overhead
48
+ fetch_state_from_socket
49
+ rescue Errno::ENOENT, Errno::ECONNREFUSED
50
+ # Daemon not running — fall back to direct API call
51
+ data = fetch_state_from_api
52
+ unless data
53
+ puts "Error: Server not reachable"
54
+ exit 1
55
+ end
56
+ data
57
+ rescue StandardError => e
58
+ puts "Error: #{e.message}"
59
+ exit 1
60
+ end
61
+
62
+ def format_elapsed(seconds)
63
+ return "#{seconds}s" if seconds < 60
64
+
65
+ minutes = seconds / 60
66
+ return "#{minutes}m" if minutes < 60
67
+
68
+ hours = minutes / 60
69
+ "#{hours}h"
70
+ end
71
+
72
+ state = fetch_state
73
+ sessions = state["sessions"] || []
74
+
75
+ if sessions.empty?
76
+ system("notify-send", "Brainiac", "No active agent sessions")
77
+ exit 0
78
+ end
79
+
80
+ # If only one session, open it directly
81
+ if sessions.size == 1
82
+ log_file = sessions[0]["log_file"]
83
+ exec("alacritty", "-e", "tail", "-f", log_file) if log_file
84
+ exit 0
85
+ end
86
+
87
+ # Multiple sessions: use fzf if available, otherwise just open the first one
88
+ if system("which fzf > /dev/null 2>&1")
89
+ # Build fzf menu
90
+ options = sessions.map do |s|
91
+ agent = s["agent"]
92
+ elapsed = format_elapsed(s["elapsed_seconds"])
93
+
94
+ card_key = s["card_key"]
95
+ context = if card_key.start_with?("discord-")
96
+ "Discord chat"
97
+ elsif card_key.start_with?("card-")
98
+ card_key.split("-")[1]
99
+ else
100
+ card_key
101
+ end
102
+
103
+ emoji = AGENTS[agent.downcase] || DEFAULT_EMOJI
104
+
105
+ "#{emoji} #{agent}: #{context} (#{elapsed})|#{s["log_file"]}"
106
+ end
107
+
108
+ menu_text = options.join("\n")
109
+ selected = `echo "#{menu_text}" | fzf --prompt="Agent Logs: "`.strip
110
+
111
+ unless selected.empty?
112
+ log_file = selected.split("|").last
113
+ exec("alacritty", "-e", "tail", "-f", log_file)
114
+ end
115
+ else
116
+ # No menu system, just open the first log
117
+ log_file = sessions[0]["log_file"]
118
+ exec("alacritty", "-e", "tail", "-f", log_file) if log_file
119
+ end