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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +2 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/brainiac +1521 -0
- data/brainiac.gemspec +30 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/brainiac/agents.rb +203 -0
- data/lib/brainiac/brain.rb +197 -0
- data/lib/brainiac/card_index.rb +389 -0
- data/lib/brainiac/config.rb +263 -0
- data/lib/brainiac/cron.rb +629 -0
- data/lib/brainiac/deployments.rb +258 -0
- data/lib/brainiac/handlers/discord.rb +1643 -0
- data/lib/brainiac/handlers/fizzy.rb +1249 -0
- data/lib/brainiac/handlers/github.rb +598 -0
- data/lib/brainiac/handlers/zoho.rb +487 -0
- data/lib/brainiac/helpers.rb +760 -0
- data/lib/brainiac/planning.rb +237 -0
- data/lib/brainiac/prompts.rb +620 -0
- data/lib/brainiac/sessions.rb +282 -0
- data/lib/brainiac/skills.rb +276 -0
- data/lib/brainiac/users.rb +76 -0
- data/lib/brainiac/version.rb +6 -0
- data/lib/brainiac/zoho_mail_api.rb +109 -0
- data/lib/brainiac.rb +10 -0
- data/lib/user_registry.rb +159 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data.tar.gz.sig +0 -0
- metadata +235 -0
- 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
|