zillacore 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 +0 -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/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -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/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# ZillaCore Waybar Deployments Module
|
|
5
|
+
# Polls /api/deployments and outputs JSON for waybar
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require "net/http"
|
|
9
|
+
require "shellwords"
|
|
10
|
+
require "uri"
|
|
11
|
+
require "time"
|
|
12
|
+
|
|
13
|
+
SERVER_URL = "http://localhost:4567"
|
|
14
|
+
|
|
15
|
+
def fetch_deployments
|
|
16
|
+
uri = URI("#{SERVER_URL}/api/deployments")
|
|
17
|
+
response = Net::HTTP.get_response(uri)
|
|
18
|
+
JSON.parse(response.body)["deployments"] || []
|
|
19
|
+
rescue StandardError
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def time_ago(iso_time)
|
|
24
|
+
return nil unless iso_time
|
|
25
|
+
|
|
26
|
+
seconds = (Time.now - Time.parse(iso_time)).to_i
|
|
27
|
+
return "#{seconds}s ago" if seconds < 60
|
|
28
|
+
|
|
29
|
+
minutes = seconds / 60
|
|
30
|
+
return "#{minutes}m ago" if minutes < 60
|
|
31
|
+
|
|
32
|
+
hours = minutes / 60
|
|
33
|
+
return "#{hours}h ago" if hours < 24
|
|
34
|
+
|
|
35
|
+
"#{hours / 24}d ago"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generate_output
|
|
39
|
+
deployments = fetch_deployments
|
|
40
|
+
unless deployments
|
|
41
|
+
puts({ text: "", tooltip: "Deploy tracker: server unreachable", class: "error" }.to_json)
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if deployments.empty?
|
|
46
|
+
puts({ text: "", tooltip: "No environments configured", class: "empty" }.to_json)
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
recent_window = 30 * 60 # 30 minutes
|
|
51
|
+
|
|
52
|
+
dots = deployments.map do |d|
|
|
53
|
+
if d["status"] == "occupied"
|
|
54
|
+
deploy_time = d["last_deploy_at"] || d["deployed_at"]
|
|
55
|
+
recent = deploy_time && (Time.now - Time.parse(deploy_time)) < recent_window
|
|
56
|
+
status = d["last_deploy_status"]
|
|
57
|
+
|
|
58
|
+
if status == "failed"
|
|
59
|
+
'<span color="#ff4444" background="#440000">●</span>'
|
|
60
|
+
elsif recent && status == "success"
|
|
61
|
+
'<span color="#4488ff">●</span>'
|
|
62
|
+
else
|
|
63
|
+
'<span color="#ff4444">●</span>'
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
'<span color="#44ff44">●</span>'
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
text = dots.join(" ")
|
|
70
|
+
|
|
71
|
+
# Determine CSS class based on deploy states
|
|
72
|
+
has_recent_success = deployments.any? do |d|
|
|
73
|
+
t = d["last_deploy_at"] || d["deployed_at"]
|
|
74
|
+
d["last_deploy_status"] == "success" && t && (Time.now - Time.parse(t)) < recent_window
|
|
75
|
+
end
|
|
76
|
+
has_failure = deployments.any? { |d| d["last_deploy_status"] == "failed" }
|
|
77
|
+
|
|
78
|
+
css_class = if has_failure
|
|
79
|
+
"deploy-failed"
|
|
80
|
+
elsif has_recent_success
|
|
81
|
+
"deploy-recent"
|
|
82
|
+
else
|
|
83
|
+
"deployments"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
tooltip_lines = deployments.map do |d|
|
|
87
|
+
label = d["label"] || d["env"]
|
|
88
|
+
if d["status"] == "occupied"
|
|
89
|
+
card = d["card_number"] ? "##{d["card_number"]}" : d["branch"] || "unknown"
|
|
90
|
+
branch = d["branch"] ? " — #{d["branch"]}" : ""
|
|
91
|
+
ago = time_ago(d["deployed_at"])
|
|
92
|
+
status_icon = case d["last_deploy_status"]
|
|
93
|
+
when "failed" then "💥"
|
|
94
|
+
when "success"
|
|
95
|
+
t = d["last_deploy_at"] || d["deployed_at"]
|
|
96
|
+
t && (Time.now - Time.parse(t)) < recent_window ? "🚀✅" : "🔴"
|
|
97
|
+
else "🔴"
|
|
98
|
+
end
|
|
99
|
+
"#{status_icon} #{label}: #{card}#{branch}#{" (#{ago})" if ago}"
|
|
100
|
+
else
|
|
101
|
+
ago = time_ago(d["cleared_at"])
|
|
102
|
+
last = d["last_card"] ? " (was ##{d["last_card"]})" : ""
|
|
103
|
+
"🟢 #{label}: Available#{" #{ago}" if ago}#{last}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
puts({ text: text, tooltip: tooltip_lines.join("\n"), class: css_class }.to_json)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def resize_deploy_terminal
|
|
111
|
+
script = "sleep 0.5 && " \
|
|
112
|
+
'width=$(hyprctl monitors -j | ruby -rjson -e "puts JSON.parse(STDIN.read)[0][%q(width)]") && ' \
|
|
113
|
+
"delta=$(( (width / 2) - (width * 15 / 100) )) && " \
|
|
114
|
+
'hyprctl --batch "dispatch focuswindow class:zillacore-deploy; dispatch resizeactive -${delta} 0"'
|
|
115
|
+
spawn("bash", "-c", script, %i[out err] => "/dev/null")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def handle_click
|
|
119
|
+
deployments = fetch_deployments
|
|
120
|
+
return unless deployments&.any?
|
|
121
|
+
|
|
122
|
+
# If any environment has a failed deploy, show the log
|
|
123
|
+
failed = deployments.find { |d| d["last_deploy_status"] == "failed" && d["last_deploy_log"] }
|
|
124
|
+
if failed && File.exist?(failed["last_deploy_log"].to_s)
|
|
125
|
+
spawn("alacritty", "-e", "bash", "-c",
|
|
126
|
+
"echo '=== Deploy failure: #{failed["label"] || failed["env"]} ===' && echo && cat #{Shellwords.escape(failed["last_deploy_log"])} && echo && echo 'Press Enter to close...' && read",
|
|
127
|
+
%i[out err] => "/dev/null")
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Otherwise open environment URLs
|
|
132
|
+
options = deployments.filter_map do |d|
|
|
133
|
+
url = d["url"]
|
|
134
|
+
next unless url
|
|
135
|
+
|
|
136
|
+
label = d["label"] || d["env"]
|
|
137
|
+
status = d["status"] == "occupied" ? "🔴" : "🟢"
|
|
138
|
+
card = d["card_number"] ? " ##{d["card_number"]}" : ""
|
|
139
|
+
["#{status} #{label}#{card}", url]
|
|
140
|
+
end
|
|
141
|
+
return if options.empty?
|
|
142
|
+
|
|
143
|
+
if options.length == 1
|
|
144
|
+
spawn("xdg-open", options[0][1], %i[out err] => "/dev/null")
|
|
145
|
+
else
|
|
146
|
+
labels = options.map(&:first)
|
|
147
|
+
choice = `timeout 30 zenity --list --title="Open Environment" --column="Environment" #{labels.map do |l|
|
|
148
|
+
Shellwords.escape(l)
|
|
149
|
+
end.join(" ")} 2>/dev/null`.strip
|
|
150
|
+
return if choice.empty?
|
|
151
|
+
|
|
152
|
+
selected = options.find { |label, _| label == choice }
|
|
153
|
+
spawn("xdg-open", selected[1], %i[out err] => "/dev/null") if selected
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def handle_deploy
|
|
158
|
+
deployments = fetch_deployments
|
|
159
|
+
return unless deployments&.any?
|
|
160
|
+
|
|
161
|
+
# Pick environment
|
|
162
|
+
envs = deployments.map { |d| [d["env"], d["label"] || d["env"]] }
|
|
163
|
+
if envs.length == 1
|
|
164
|
+
env_key = envs[0][0]
|
|
165
|
+
else
|
|
166
|
+
labels = envs.map { |key, label| "#{key}|#{label}" }
|
|
167
|
+
choice = `timeout 30 zenity --list --title="Deploy to..." --column="Env" --column="Label" #{labels.map do |l|
|
|
168
|
+
l.split("|").map do |p|
|
|
169
|
+
Shellwords.escape(p)
|
|
170
|
+
end.join(" ")
|
|
171
|
+
end.join(" ")} 2>/dev/null`.strip
|
|
172
|
+
return if choice.empty?
|
|
173
|
+
|
|
174
|
+
env_key = choice
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get card number — pre-fill with current card if environment is occupied
|
|
178
|
+
selected_dep = deployments.find { |d| d["env"] == env_key }
|
|
179
|
+
prefill = selected_dep && selected_dep["status"] == "occupied" && selected_dep["card_number"] ? selected_dep["card_number"].to_s : ""
|
|
180
|
+
card_number = `timeout 60 zenity --entry --title="Deploy to #{env_key}" --text="Fizzy card number:"#{unless prefill.empty?
|
|
181
|
+
" --entry-text=#{Shellwords.escape(prefill)}"
|
|
182
|
+
end} 2>/dev/null`.strip
|
|
183
|
+
return if card_number.empty?
|
|
184
|
+
|
|
185
|
+
# Resolve worktree via glob (same pattern as fz shell function)
|
|
186
|
+
matches = Dir.glob(File.expand_path("~/Code/*fizzy-#{card_number}-*/"))
|
|
187
|
+
worktree = matches.find { |d| File.directory?(d) }
|
|
188
|
+
unless worktree
|
|
189
|
+
`timeout 10 zenity --error --text="No worktree found for card ##{card_number}" 2>/dev/null`
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
deploy_script = <<~BASH
|
|
194
|
+
cd #{Shellwords.escape(worktree)}
|
|
195
|
+
echo "🚀 #{env_key} deploy in progress..."
|
|
196
|
+
echo
|
|
197
|
+
logfile=$(mktemp)
|
|
198
|
+
./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1 | tee "$logfile"
|
|
199
|
+
status=${PIPESTATUS[0]}
|
|
200
|
+
if [ $status -ne 0 ] && grep -q "checksums previously recorded in the dependency lock file" "$logfile"; then
|
|
201
|
+
echo
|
|
202
|
+
echo "⚠️ Terraform lock file mismatch — removing lock and running init -upgrade..."
|
|
203
|
+
echo
|
|
204
|
+
rm -f infrastructure/#{Shellwords.escape(env_key)}/.terraform.lock.hcl
|
|
205
|
+
(cd infrastructure/#{Shellwords.escape(env_key)} && terraform init -upgrade)
|
|
206
|
+
echo
|
|
207
|
+
echo "🔄 Retrying deploy..."
|
|
208
|
+
echo
|
|
209
|
+
./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1
|
|
210
|
+
status=$?
|
|
211
|
+
fi
|
|
212
|
+
rm -f "$logfile"
|
|
213
|
+
echo
|
|
214
|
+
if [ $status -eq 0 ]; then echo "✅ Deploy complete"; else echo "❌ Deploy failed (exit $status)"; fi
|
|
215
|
+
echo "Press Enter to close..."
|
|
216
|
+
read
|
|
217
|
+
BASH
|
|
218
|
+
|
|
219
|
+
# Mark deploying via API so waybar turns orange immediately
|
|
220
|
+
begin
|
|
221
|
+
uri = URI("#{SERVER_URL}/api/deployments/#{env_key}/deploying")
|
|
222
|
+
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
|
|
223
|
+
req.body = { worktree: worktree }.to_json
|
|
224
|
+
Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
|
|
225
|
+
rescue StandardError
|
|
226
|
+
# Non-fatal — deploy proceeds even if server is unreachable
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
spawn("alacritty", "--class", "zillacore-deploy", "-e", "bash", "-c", deploy_script, %i[out err] => "/dev/null")
|
|
230
|
+
resize_deploy_terminal
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if ARGV.include?("--click")
|
|
234
|
+
handle_click
|
|
235
|
+
elsif ARGV.include?("--deploy")
|
|
236
|
+
handle_deploy
|
|
237
|
+
else
|
|
238
|
+
generate_output
|
|
239
|
+
end
|
data/monitor/waybar.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# ZillaCore Waybar Module
|
|
5
|
+
# Reads from monitor daemon socket and outputs JSON for waybar
|
|
6
|
+
# Single module that updates content dynamically (no config rewrites)
|
|
7
|
+
|
|
8
|
+
require "json"
|
|
9
|
+
require "socket"
|
|
10
|
+
require "net/http"
|
|
11
|
+
|
|
12
|
+
SOCKET_PATH = "/tmp/zillacore-monitor.sock"
|
|
13
|
+
API_URL = "http://localhost:4567/api/status"
|
|
14
|
+
CONFIG_PATH = File.expand_path("~/.zillacore/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
|
+
agents
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
warn "Failed to load waybar.json: #{e.message}"
|
|
26
|
+
{}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
AGENTS = load_agent_config.freeze
|
|
30
|
+
DEFAULT_EMOJI = "❓"
|
|
31
|
+
|
|
32
|
+
def normalize_agent_name(name)
|
|
33
|
+
name.downcase
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch_state_from_socket
|
|
37
|
+
socket = UNIXSocket.new(SOCKET_PATH)
|
|
38
|
+
data = socket.read
|
|
39
|
+
socket.close
|
|
40
|
+
JSON.parse(data)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fetch_state_from_api
|
|
44
|
+
uri = URI(API_URL)
|
|
45
|
+
response = Net::HTTP.get_response(uri)
|
|
46
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
47
|
+
|
|
48
|
+
JSON.parse(response.body)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fetch_state
|
|
52
|
+
# Prefer socket (daemon mode) — faster, no HTTP overhead
|
|
53
|
+
fetch_state_from_socket
|
|
54
|
+
rescue Errno::ENOENT, Errno::ECONNREFUSED
|
|
55
|
+
# Daemon not running — fall back to direct API call
|
|
56
|
+
fetch_state_from_api || { "sessions" => [], "count" => 0, "error" => "server not reachable" }
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
{ "sessions" => [], "count" => 0, "error" => e.message }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_elapsed(seconds)
|
|
62
|
+
return "#{seconds}s" if seconds < 60
|
|
63
|
+
|
|
64
|
+
minutes = seconds / 60
|
|
65
|
+
return "#{minutes}m" if minutes < 60
|
|
66
|
+
|
|
67
|
+
hours = minutes / 60
|
|
68
|
+
"#{hours}h"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
INFRA_CMDS = %w[kiro-cli-chat ruby-lsp clangd gopls].freeze
|
|
72
|
+
|
|
73
|
+
def infra_process?(cmd_short)
|
|
74
|
+
INFRA_CMDS.any? { |ic| cmd_short.start_with?(ic) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def escape_pango(str)
|
|
78
|
+
str.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def generate_output
|
|
82
|
+
state = fetch_state
|
|
83
|
+
|
|
84
|
+
if state["error"]
|
|
85
|
+
return {
|
|
86
|
+
text: "⚠️",
|
|
87
|
+
tooltip: "ZillaCore Error: #{escape_pango(state["error"])}",
|
|
88
|
+
class: "error"
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
sessions = state["sessions"] || []
|
|
93
|
+
|
|
94
|
+
if sessions.empty?
|
|
95
|
+
return {
|
|
96
|
+
text: "💤",
|
|
97
|
+
tooltip: "No active agent sessions",
|
|
98
|
+
class: "idle"
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Build text: show emoji for each active agent
|
|
103
|
+
text_parts = sessions.map { |s| AGENTS[normalize_agent_name(s["agent"])] || DEFAULT_EMOJI }
|
|
104
|
+
text = text_parts.join(" ")
|
|
105
|
+
|
|
106
|
+
# Build tooltip
|
|
107
|
+
tooltip_lines = sessions.map do |s|
|
|
108
|
+
agent_display = s["agent"]
|
|
109
|
+
emoji = AGENTS[normalize_agent_name(agent_display)] || DEFAULT_EMOJI
|
|
110
|
+
elapsed = format_elapsed(s["elapsed_seconds"])
|
|
111
|
+
|
|
112
|
+
card_key = s["card_key"]
|
|
113
|
+
context = if card_key.start_with?("discord-")
|
|
114
|
+
"Discord chat"
|
|
115
|
+
elsif card_key.start_with?("card-")
|
|
116
|
+
card_key.split("-")[1]
|
|
117
|
+
else
|
|
118
|
+
card_key
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
lines = ["#{emoji} #{agent_display}: #{context} (#{elapsed})"]
|
|
122
|
+
|
|
123
|
+
children = (s["children"] || []).reject do |c|
|
|
124
|
+
cmd_short = c["cmd"].to_s.split("/").last.to_s.split.first.to_s
|
|
125
|
+
infra_process?(cmd_short)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
children.each do |c|
|
|
129
|
+
cmd_short = c["cmd"].to_s.split("/").last.to_s.split.first.to_s
|
|
130
|
+
cmd_short = c["cmd"].to_s[0..40] if cmd_short.empty?
|
|
131
|
+
lines << " └ #{escape_pango(cmd_short)} (#{format_elapsed(c["elapsed_seconds"])}) [PID #{c["pid"]}]"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
lines.join("\n")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
tooltip_lines << "\n[Click to manage]"
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
text: text,
|
|
141
|
+
tooltip: tooltip_lines.join("\n"),
|
|
142
|
+
class: "working"
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
puts generate_output.to_json
|
data/monitor/xbar.3s.rb
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# ZillaCore xbar Plugin (macOS menu bar)
|
|
5
|
+
# Reads from monitor daemon socket and outputs xbar-formatted text
|
|
6
|
+
# Filename encodes refresh interval: xbar.3s.rb = every 3 seconds
|
|
7
|
+
#
|
|
8
|
+
# <xbar.title>ZillaCore Agent Monitor</xbar.title>
|
|
9
|
+
# <xbar.version>v1.0</xbar.version>
|
|
10
|
+
# <xbar.author>ZillaCore</xbar.author>
|
|
11
|
+
# <xbar.desc>Shows active AI agent sessions in the macOS menu bar</xbar.desc>
|
|
12
|
+
# <xbar.dependencies>ruby</xbar.dependencies>
|
|
13
|
+
|
|
14
|
+
require "json"
|
|
15
|
+
require "shellwords"
|
|
16
|
+
require "socket"
|
|
17
|
+
|
|
18
|
+
SOCKET_PATH = "/tmp/zillacore-monitor.sock"
|
|
19
|
+
CONFIG_PATH = File.expand_path("~/.zillacore/waybar.json")
|
|
20
|
+
|
|
21
|
+
def load_agent_config
|
|
22
|
+
config = JSON.parse(File.read(CONFIG_PATH))
|
|
23
|
+
agents = {}
|
|
24
|
+
config["agents"].each do |agent|
|
|
25
|
+
agents[agent["name"].downcase] = { emoji: agent["emoji"], color: agent["color"] }
|
|
26
|
+
end
|
|
27
|
+
[agents, config["default_emoji"] || "❓"]
|
|
28
|
+
rescue StandardError
|
|
29
|
+
[{}, "❓"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
AGENTS, DEFAULT_EMOJI = load_agent_config
|
|
33
|
+
|
|
34
|
+
COLOR_MAP = {
|
|
35
|
+
"red" => "#ff5555", "green" => "#50fa7b", "blue" => "#8be9fd",
|
|
36
|
+
"yellow" => "#f1fa8c", "cyan" => "#8be9fd", "magenta" => "#ff79c6",
|
|
37
|
+
"purple" => "#bd93f9", "pink" => "#ff79c6", "white" => "#f8f8f2"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def hex_color(name)
|
|
41
|
+
COLOR_MAP[name] || name
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_state
|
|
45
|
+
socket = UNIXSocket.new(SOCKET_PATH)
|
|
46
|
+
data = socket.read
|
|
47
|
+
socket.close
|
|
48
|
+
JSON.parse(data)
|
|
49
|
+
rescue Errno::ENOENT
|
|
50
|
+
{ "error" => "daemon not running" }
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
{ "error" => e.message }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def format_elapsed(seconds)
|
|
56
|
+
return "#{seconds}s" if seconds < 60
|
|
57
|
+
|
|
58
|
+
minutes = seconds / 60
|
|
59
|
+
return "#{minutes}m" if minutes < 60
|
|
60
|
+
|
|
61
|
+
"#{minutes / 60}h"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_context(card_key)
|
|
65
|
+
return "" unless card_key
|
|
66
|
+
|
|
67
|
+
if card_key.start_with?("discord-")
|
|
68
|
+
"Discord"
|
|
69
|
+
elsif card_key.start_with?("card-")
|
|
70
|
+
"##{card_key.split("-")[1]}"
|
|
71
|
+
else
|
|
72
|
+
card_key
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def time_ago(iso_string)
|
|
77
|
+
return nil unless iso_string
|
|
78
|
+
|
|
79
|
+
seconds = (Time.now - Time.parse(iso_string)).to_i
|
|
80
|
+
"#{format_elapsed(seconds)} ago"
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
ANSI_REGEX = /\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/
|
|
86
|
+
LOG_PREVIEW_LINES = 15
|
|
87
|
+
LOG_LINE_MAX = 80
|
|
88
|
+
LOG_FONT = "SFMono-Regular"
|
|
89
|
+
LOG_SIZE = 12
|
|
90
|
+
|
|
91
|
+
def tail_log(log_file, lines: LOG_PREVIEW_LINES)
|
|
92
|
+
return [] unless log_file && File.exist?(log_file)
|
|
93
|
+
|
|
94
|
+
raw = `tail -n 50 #{log_file.shellescape} 2>/dev/null`
|
|
95
|
+
raw.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
96
|
+
.lines
|
|
97
|
+
.map { |l| l.gsub(ANSI_REGEX, "").gsub(/[^[:print:]\t]/, "").strip }
|
|
98
|
+
.reject(&:empty?)
|
|
99
|
+
.last(lines)
|
|
100
|
+
rescue StandardError
|
|
101
|
+
[]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def format_log_line(text)
|
|
105
|
+
text.length > LOG_LINE_MAX ? "#{text[0, LOG_LINE_MAX]}…" : text
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
state = fetch_state
|
|
109
|
+
|
|
110
|
+
if state["error"]
|
|
111
|
+
puts "⚠️ | color=red"
|
|
112
|
+
puts "---"
|
|
113
|
+
puts "ZillaCore: #{state["error"]} | color=red"
|
|
114
|
+
exit
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
sessions = state["sessions"] || []
|
|
118
|
+
recent = state["recent"] || []
|
|
119
|
+
view_logs_script = File.join(__dir__, "view-logs-macos.rb")
|
|
120
|
+
|
|
121
|
+
# Menu bar title
|
|
122
|
+
if sessions.any?
|
|
123
|
+
puts sessions.map { |s| AGENTS.dig(s["agent"]&.downcase, :emoji) || DEFAULT_EMOJI }.join(" ")
|
|
124
|
+
else
|
|
125
|
+
puts "💤"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
puts "---"
|
|
129
|
+
|
|
130
|
+
# Active sessions
|
|
131
|
+
if sessions.any?
|
|
132
|
+
puts "Active | size=12"
|
|
133
|
+
sessions.each do |s|
|
|
134
|
+
agent = s["agent"] || "Unknown"
|
|
135
|
+
info = AGENTS[agent.downcase] || {}
|
|
136
|
+
emoji = info[:emoji] || DEFAULT_EMOJI
|
|
137
|
+
color = info[:color] ? " | color=#{hex_color(info[:color])}" : ""
|
|
138
|
+
elapsed = format_elapsed(s["elapsed_seconds"] || 0)
|
|
139
|
+
context = format_context(s["card_key"])
|
|
140
|
+
|
|
141
|
+
puts "#{emoji} #{agent}: #{context} (#{elapsed})#{color}"
|
|
142
|
+
|
|
143
|
+
log_lines = tail_log(s["log_file"])
|
|
144
|
+
if log_lines.any?
|
|
145
|
+
log_lines.each do |line|
|
|
146
|
+
puts "-- #{format_log_line(line)} | font=#{LOG_FONT} size=#{LOG_SIZE}"
|
|
147
|
+
end
|
|
148
|
+
puts "-- ---"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
puts "-- Open Full Log | shell=#{view_logs_script} param1=#{s["log_file"]} terminal=false refresh=false" if s["log_file"]
|
|
152
|
+
end
|
|
153
|
+
else
|
|
154
|
+
puts "No active sessions | size=12"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Recent completed sessions
|
|
158
|
+
if recent.any?
|
|
159
|
+
puts "---"
|
|
160
|
+
puts "Recent | size=12"
|
|
161
|
+
recent.each do |s|
|
|
162
|
+
agent = s["agent"] || "Unknown"
|
|
163
|
+
emoji = AGENTS.dig(agent.downcase, :emoji) || DEFAULT_EMOJI
|
|
164
|
+
context = format_context(s["card_key"])
|
|
165
|
+
ago = time_ago(s["finished_at"]) || "?"
|
|
166
|
+
|
|
167
|
+
puts "#{emoji} #{agent}: #{context} — #{ago}"
|
|
168
|
+
|
|
169
|
+
log_lines = tail_log(s["log_file"])
|
|
170
|
+
if log_lines.any?
|
|
171
|
+
log_lines.each do |line|
|
|
172
|
+
puts "-- #{format_log_line(line)} | font=#{LOG_FONT} size=#{LOG_SIZE}"
|
|
173
|
+
end
|
|
174
|
+
puts "-- ---"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
puts "-- Open Full Log | shell=#{view_logs_script} param1=#{s["log_file"]} terminal=false refresh=false" if s["log_file"]
|
|
178
|
+
end
|
|
179
|
+
end
|