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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -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/zillacore +1521 -0
  9. data/certs/stowzilla.pem +26 -0
  10. data/docs/waybar-config.md +96 -0
  11. data/lib/user_registry.rb +159 -0
  12. data/lib/zillacore/agents.rb +203 -0
  13. data/lib/zillacore/brain.rb +197 -0
  14. data/lib/zillacore/card_index.rb +389 -0
  15. data/lib/zillacore/config.rb +263 -0
  16. data/lib/zillacore/cron.rb +629 -0
  17. data/lib/zillacore/deployments.rb +258 -0
  18. data/lib/zillacore/handlers/discord.rb +1643 -0
  19. data/lib/zillacore/handlers/fizzy.rb +1249 -0
  20. data/lib/zillacore/handlers/github.rb +598 -0
  21. data/lib/zillacore/handlers/zoho.rb +487 -0
  22. data/lib/zillacore/helpers.rb +760 -0
  23. data/lib/zillacore/planning.rb +237 -0
  24. data/lib/zillacore/prompts.rb +620 -0
  25. data/lib/zillacore/sessions.rb +282 -0
  26. data/lib/zillacore/skills.rb +276 -0
  27. data/lib/zillacore/users.rb +76 -0
  28. data/lib/zillacore/version.rb +6 -0
  29. data/lib/zillacore/zoho_mail_api.rb +109 -0
  30. data/lib/zillacore.rb +10 -0
  31. data/monitor/daemon.rb +99 -0
  32. data/monitor/deploy-env-macos.rb +131 -0
  33. data/monitor/menubar.rb +295 -0
  34. data/monitor/open-action.sh +15 -0
  35. data/monitor/setup-menubar.rb +78 -0
  36. data/monitor/setup-waybar-deploy-envs.rb +121 -0
  37. data/monitor/setup-waybar-deployments.rb +96 -0
  38. data/monitor/setup-waybar-module.rb +113 -0
  39. data/monitor/setup-xbar-plugin.rb +35 -0
  40. data/monitor/view-logs-macos.rb +210 -0
  41. data/monitor/view-logs-rofi.rb +194 -0
  42. data/monitor/view-logs.rb +119 -0
  43. data/monitor/waybar-config-updater.rb +56 -0
  44. data/monitor/waybar-deploy-env.rb +206 -0
  45. data/monitor/waybar-deployments.rb +239 -0
  46. data/monitor/waybar.rb +146 -0
  47. data/monitor/xbar.3s.rb +179 -0
  48. data/receiver.rb +956 -0
  49. data/templates/agents.json.example +10 -0
  50. data/templates/discord.json.example +17 -0
  51. data/templates/fizzy.json.example +24 -0
  52. data/templates/github.json.example +4 -0
  53. data/templates/testflight.json.example +8 -0
  54. data/templates/users.json.example +121 -0
  55. data/templates/zoho.json.example +27 -0
  56. data/views/dashboard.erb +437 -0
  57. data/zillacore.gemspec +30 -0
  58. data.tar.gz.sig +2 -0
  59. metadata +235 -0
  60. metadata.gz.sig +0 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Zoho Mail REST API client for fetching email content.
4
+ # Used when webhook payloads don't include the body (which is most of the time).
5
+ #
6
+ # Requires zoho.json to have:
7
+ # "api": {
8
+ # "client_id": "...",
9
+ # "client_secret": "...",
10
+ # "refresh_token": "...",
11
+ # "account_id": "..."
12
+ # }
13
+
14
+ ZOHO_TOKEN_URL = "https://accounts.zoho.com/oauth/v2/token"
15
+ ZOHO_MAIL_API_BASE = "https://mail.zoho.com/api/accounts"
16
+
17
+ # In-memory cache for access token (expires after ~55 min to be safe)
18
+ @zoho_access_token = nil
19
+ @zoho_token_expires_at = Time.at(0)
20
+
21
+ def zoho_api_configured?
22
+ api = ZOHO_CONFIG["api"]
23
+ api && api["client_id"] && api["client_secret"] && api["refresh_token"] && api["account_id"]
24
+ end
25
+
26
+ def zoho_refresh_access_token!
27
+ api = ZOHO_CONFIG["api"]
28
+ uri = URI(ZOHO_TOKEN_URL)
29
+ res = Net::HTTP.post_form(uri, {
30
+ "grant_type" => "refresh_token",
31
+ "client_id" => api["client_id"],
32
+ "client_secret" => api["client_secret"],
33
+ "refresh_token" => api["refresh_token"]
34
+ })
35
+
36
+ data = JSON.parse(res.body)
37
+ if data["access_token"]
38
+ @zoho_access_token = data["access_token"]
39
+ @zoho_token_expires_at = Time.now + 3300 # ~55 min
40
+ LOG.info "[Zoho:API] Refreshed access token"
41
+ @zoho_access_token
42
+ else
43
+ LOG.error "[Zoho:API] Token refresh failed: #{data["error"]}"
44
+ nil
45
+ end
46
+ rescue StandardError => e
47
+ LOG.error "[Zoho:API] Token refresh error: #{e.message}"
48
+ nil
49
+ end
50
+
51
+ def zoho_access_token
52
+ return @zoho_access_token if @zoho_access_token && Time.now < @zoho_token_expires_at
53
+
54
+ zoho_refresh_access_token!
55
+ end
56
+
57
+ # Fetch email content by messageId using the "original message" endpoint.
58
+ # This endpoint doesn't require a folder ID — just accountId + messageId.
59
+ # Returns plain-text body extracted from the MIME content, or nil.
60
+ def fetch_zoho_email_content(message_id)
61
+ return nil unless zoho_api_configured?
62
+
63
+ token = zoho_access_token
64
+ return nil unless token
65
+
66
+ account_id = ZOHO_CONFIG.dig("api", "account_id")
67
+ uri = URI("#{ZOHO_MAIL_API_BASE}/#{account_id}/messages/#{message_id}/originalmessage")
68
+
69
+ http = Net::HTTP.new(uri.host, uri.port)
70
+ http.use_ssl = true
71
+ req = Net::HTTP::Get.new(uri)
72
+ req["Authorization"] = "Zoho-oauthtoken #{token}"
73
+ req["Accept"] = "application/json"
74
+
75
+ res = http.request(req)
76
+ data = JSON.parse(res.body)
77
+
78
+ if data.dig("status", "code") == 200
79
+ raw_mime = data.dig("data", "content").to_s
80
+ text = extract_text_from_mime(raw_mime)
81
+ LOG.info "[Zoho:API] Fetched content for message #{message_id} (#{text.length} chars)"
82
+ text
83
+ else
84
+ LOG.warn "[Zoho:API] Failed to fetch content: #{data.dig("status", "description")}"
85
+ nil
86
+ end
87
+ rescue StandardError => e
88
+ LOG.error "[Zoho:API] Error fetching content: #{e.message}"
89
+ nil
90
+ end
91
+
92
+ # Extract readable text from raw MIME content.
93
+ # Prefers text/plain part; falls back to stripping HTML from text/html part.
94
+ def extract_text_from_mime(mime)
95
+ # Try to find text/plain part
96
+ if mime =~ %r{Content-Type: text/plain[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
97
+ return Regexp.last_match(1).gsub("\r\n", "\n").strip
98
+ end
99
+
100
+ # Fallback: extract text/html part and strip tags
101
+ if mime =~ %r{Content-Type: text/html[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
102
+ html = Regexp.last_match(1).gsub("\r\n", "\n")
103
+ return html.gsub(/<[^>]+>/, " ").gsub(/&nbsp;/i, " ").gsub(/&amp;/i, "&")
104
+ .gsub(/&lt;/i, "<").gsub(/&gt;/i, ">").gsub(/\s+/, " ").strip
105
+ end
106
+
107
+ # Last resort: strip all HTML-ish content from the whole thing
108
+ mime.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
109
+ end
data/lib/zillacore.rb ADDED
@@ -0,0 +1,10 @@
1
+ require_relative "zillacore/config"
2
+ require_relative "zillacore/users"
3
+ require_relative "zillacore/agents"
4
+ require_relative "zillacore/brain"
5
+ require_relative "zillacore/skills"
6
+ require_relative "zillacore/sessions"
7
+ require_relative "zillacore/prompts"
8
+ require_relative "zillacore/planning"
9
+ require_relative "zillacore/helpers"
10
+ require_relative "zillacore/cron"
data/monitor/daemon.rb ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ZillaCore Monitor Daemon
5
+ # Polls /api/status and exposes agent state via Unix socket for waybar
6
+ # No longer triggers config updates - waybar module polls this socket directly
7
+
8
+ require "json"
9
+ require "net/http"
10
+ require "socket"
11
+
12
+ SOCKET_PATH = "/tmp/zillacore-monitor.sock"
13
+ API_URL = "http://localhost:4567/api/status"
14
+ POLL_INTERVAL = 2 # seconds
15
+ CONFIG_PATH = File.expand_path("~/.zillacore/waybar.json")
16
+
17
+ # Load agent configuration from JSON
18
+ def load_agent_config
19
+ config = JSON.parse(File.read(CONFIG_PATH))
20
+ agents = {}
21
+ config["agents"].each do |agent|
22
+ agents[agent["name"]] = { color: agent["color"], emoji: agent["emoji"] }
23
+ end
24
+ agents
25
+ rescue StandardError => e
26
+ warn "Failed to load waybar.json: #{e.message}"
27
+ {}
28
+ end
29
+
30
+ AGENTS = load_agent_config.freeze
31
+
32
+ @state = { sessions: [], count: 0, recent: [], last_update: nil }
33
+
34
+ def fetch_status
35
+ uri = URI(API_URL)
36
+ response = Net::HTTP.get_response(uri)
37
+ return nil unless response.is_a?(Net::HTTPSuccess)
38
+
39
+ JSON.parse(response.body)
40
+ rescue StandardError => e
41
+ warn "Failed to fetch status: #{e.message}"
42
+ nil
43
+ end
44
+
45
+ def update_state
46
+ data = fetch_status
47
+ return unless data
48
+
49
+ @state = {
50
+ sessions: data["sessions"],
51
+ count: data["count"],
52
+ recent: data["recent"] || [],
53
+ last_update: Time.now.to_i
54
+ }
55
+ end
56
+
57
+ def handle_client(client)
58
+ client.puts @state.to_json
59
+ client.close
60
+ rescue StandardError => e
61
+ warn "Error handling client: #{e.message}"
62
+ end
63
+
64
+ def start_server
65
+ FileUtils.rm_f(SOCKET_PATH)
66
+
67
+ server = UNIXServer.new(SOCKET_PATH)
68
+ File.chmod(0o666, SOCKET_PATH)
69
+
70
+ # Write PID file
71
+ File.write("/tmp/zillacore-daemon.pid", Process.pid)
72
+
73
+ puts "Monitor daemon started, socket: #{SOCKET_PATH}"
74
+
75
+ # Start polling thread
76
+ poller = Thread.new do
77
+ loop do
78
+ update_state
79
+ sleep POLL_INTERVAL
80
+ end
81
+ end
82
+
83
+ # Initial state fetch
84
+ update_state
85
+
86
+ # Accept client connections
87
+ loop do
88
+ client = server.accept
89
+ Thread.new { handle_client(client) }
90
+ end
91
+ rescue Interrupt
92
+ puts "\nShutting down..."
93
+ poller&.kill
94
+ FileUtils.rm_f(SOCKET_PATH)
95
+ FileUtils.rm_f("/tmp/zillacore-daemon.pid")
96
+ exit 0
97
+ end
98
+
99
+ start_server
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ZillaCore macOS Deploy Action
5
+ # Prompts for a Fizzy card number via osascript, finds the worktree, and deploys.
6
+ # Usage: deploy-env-macos.rb <env_key>
7
+
8
+ require "English"
9
+ require "json"
10
+ require "net/http"
11
+ require "shellwords"
12
+ require "uri"
13
+
14
+ SERVER_URL = "http://localhost:4567"
15
+
16
+ env_key = ARGV[0]
17
+ exit unless env_key
18
+
19
+ def fetch_deployments
20
+ uri = URI("#{SERVER_URL}/api/deployments")
21
+ response = Net::HTTP.get_response(uri)
22
+ JSON.parse(response.body)["deployments"] || []
23
+ rescue StandardError
24
+ nil
25
+ end
26
+
27
+ deployments = fetch_deployments
28
+ deployment = deployments&.find { |d| d["env"] == env_key }
29
+
30
+ prefill = deployment && deployment["status"] == "occupied" && deployment["card_number"] ? deployment["card_number"].to_s : ""
31
+
32
+ # Prompt via AppleScript
33
+ prompt_script = <<~APPLESCRIPT
34
+ set defaultAnswer to "#{prefill}"
35
+ set dialogResult to display dialog "Fizzy card number:" default answer defaultAnswer with title "Deploy to #{env_key}" buttons {"Cancel", "Deploy"} default button "Deploy"
36
+ return text returned of dialogResult
37
+ APPLESCRIPT
38
+
39
+ card_number = `osascript -e #{Shellwords.escape(prompt_script)} 2>/dev/null`.strip
40
+ exit if card_number.empty?
41
+
42
+ # Find worktree via card_map.json
43
+ card_map_path = File.expand_path("~/.zillacore/card_map.json")
44
+ worktree = nil
45
+ if File.exist?(card_map_path)
46
+ card_map = begin
47
+ JSON.parse(File.read(card_map_path))
48
+ rescue StandardError
49
+ {}
50
+ end
51
+ entry = card_map.values.find { |e| e["card_number"].to_s == card_number }
52
+ worktree = entry["worktree"] if entry && entry["worktree"] && File.directory?(entry["worktree"].to_s)
53
+ end
54
+
55
+ # Fallback: glob for worktree directories
56
+ unless worktree
57
+ matches = Dir.glob(File.expand_path("~/projects/sogholdings/*fizzy-#{card_number}-*/"))
58
+ worktree = matches.find { |d| File.directory?(d) }
59
+ end
60
+
61
+ unless worktree
62
+ system("osascript", "-e",
63
+ "display dialog \"No worktree found for card ##{card_number}\" buttons {\"OK\"} default button \"OK\" with title \"Deploy Failed\" with icon stop")
64
+ exit
65
+ end
66
+
67
+ # Resolve AWS_PROFILE
68
+ config_file = File.expand_path("~/.zillacore/deployments.json")
69
+ aws_profile = nil
70
+ if File.exist?(config_file)
71
+ cfg = begin
72
+ JSON.parse(File.read(config_file))
73
+ rescue StandardError
74
+ {}
75
+ end
76
+ aws_profile = cfg.dig("environments", env_key, "aws_profile")
77
+ end
78
+
79
+ # Mark deploying
80
+ begin
81
+ uri = URI("#{SERVER_URL}/api/deployments/#{env_key}/deploying")
82
+ req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
83
+ req.body = { worktree: worktree }.to_json
84
+ Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
85
+ rescue StandardError
86
+ # Non-fatal
87
+ end
88
+
89
+ # Build deploy script
90
+ deploy_script = <<~BASH
91
+ cd #{Shellwords.escape(worktree)}
92
+ #{"export AWS_PROFILE=#{Shellwords.escape(aws_profile)}" if aws_profile}
93
+ echo "🚀 Deploying card ##{card_number} to #{env_key}..."
94
+ echo " Worktree: #{worktree}"
95
+ echo
96
+ logfile=$(mktemp)
97
+ ./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1 | tee "$logfile"
98
+ status=${PIPESTATUS[0]}
99
+ if [ $status -ne 0 ] && grep -q "checksums previously recorded in the dependency lock file" "$logfile"; then
100
+ echo
101
+ echo "⚠️ Terraform lock file mismatch — removing lock and running init -upgrade..."
102
+ echo
103
+ rm -f infrastructure/#{Shellwords.escape(env_key)}/.terraform.lock.hcl
104
+ (cd infrastructure/#{Shellwords.escape(env_key)} && terraform init -upgrade)
105
+ echo
106
+ echo "🔄 Retrying deploy..."
107
+ echo
108
+ ./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1
109
+ status=$?
110
+ fi
111
+ rm -f "$logfile"
112
+ echo
113
+ if [ $status -eq 0 ]; then echo "✅ Deploy complete"; else echo "❌ Deploy failed (exit $status)"; fi
114
+ echo
115
+ echo "Press any key to close..."
116
+ read -n 1
117
+ BASH
118
+
119
+ # Write to temp file and run in Terminal.app
120
+ script_file = "/tmp/zillacore-deploy-#{env_key}-#{$PROCESS_ID}.sh"
121
+ File.write(script_file, deploy_script)
122
+ File.chmod(0o755, script_file)
123
+
124
+ terminal_script = <<~APPLESCRIPT
125
+ tell application "Terminal"
126
+ activate
127
+ do script "#{script_file.gsub('"', '\\"')}; rm -f #{script_file.gsub('"', '\\"')}"
128
+ end tell
129
+ APPLESCRIPT
130
+
131
+ system("osascript", "-e", terminal_script)
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ZillaCore macOS Menu Bar Plugin (xbar/SwiftBar)
5
+ # Reads from monitor daemon socket and outputs xbar-format text
6
+ # Mirrors monitor/waybar.rb patterns for macOS-native display
7
+
8
+ require "json"
9
+ require "net/http"
10
+ require "shellwords"
11
+ require "socket"
12
+ require "time"
13
+ require "uri"
14
+
15
+ SERVER_URL = "http://localhost:4567"
16
+ SOCKET_PATH = "/tmp/zillacore-monitor.sock"
17
+ CONFIG_PATH = File.expand_path("~/.zillacore/waybar.json")
18
+ DEFAULT_EMOJI = "❓"
19
+ SELF_PATH = File.realpath(__FILE__)
20
+
21
+ def load_config
22
+ JSON.parse(File.read(CONFIG_PATH))
23
+ rescue StandardError => e
24
+ warn "Failed to load waybar.json: #{e.message}"
25
+ {}
26
+ end
27
+
28
+ CONFIG = load_config.freeze
29
+
30
+ def load_agent_config
31
+ agents = {}
32
+ (CONFIG["agents"] || []).each do |agent|
33
+ agents[agent["name"].downcase] = { emoji: agent["emoji"], color: agent["color"] }
34
+ end
35
+ agents
36
+ end
37
+
38
+ AGENTS = load_agent_config.freeze
39
+ FIZZY_ACCOUNT_ID = CONFIG["fizzy_account_id"]
40
+ DISCORD_GUILD_ID = CONFIG["discord_guild_id"]
41
+
42
+ def fetch_state
43
+ socket = UNIXSocket.new(SOCKET_PATH)
44
+ data = socket.read
45
+ socket.close
46
+ JSON.parse(data)
47
+ rescue Errno::ENOENT
48
+ { "sessions" => [], "count" => 0, "recent" => [], "error" => "daemon not running" }
49
+ rescue StandardError => e
50
+ { "sessions" => [], "count" => 0, "recent" => [], "error" => e.message }
51
+ end
52
+
53
+ def fetch_deployments
54
+ uri = URI("#{SERVER_URL}/api/deployments")
55
+ response = Net::HTTP.get_response(uri)
56
+ JSON.parse(response.body)["deployments"] || []
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ DEPLOY_RECENT_WINDOW = 30 * 60
62
+
63
+ def deploy_dot(dep)
64
+ status = dep["last_deploy_status"]
65
+ if status == "deploying"
66
+ "🟠"
67
+ elsif status == "failed"
68
+ "💥"
69
+ elsif dep["status"] == "occupied"
70
+ deploy_time = dep["last_deploy_at"] || dep["deployed_at"]
71
+ recent = deploy_time && (Time.now - Time.parse(deploy_time)) < DEPLOY_RECENT_WINDOW
72
+ recent ? "🚀" : "🔴"
73
+ else
74
+ "🟢"
75
+ end
76
+ end
77
+
78
+ LOG_VIEWER_PATH = File.join(File.dirname(SELF_PATH), "view-logs-macos.rb")
79
+ DEPLOY_SCRIPT_PATH = File.join(File.dirname(SELF_PATH), "deploy-env-macos.rb")
80
+
81
+ ANSI_REGEX = /\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/
82
+ LOG_PREVIEW_LINES = 15
83
+ LOG_LINE_MAX = 80
84
+ LOG_FONT = "SFMono-Regular"
85
+ LOG_SIZE = 12
86
+
87
+ def tail_log(log_file, lines: LOG_PREVIEW_LINES)
88
+ return [] unless log_file && File.exist?(log_file)
89
+
90
+ raw = `tail -n 50 #{log_file.shellescape} 2>/dev/null`
91
+ raw.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
92
+ .lines
93
+ .map { |l| l.gsub(ANSI_REGEX, "").gsub(/[^[:print:]\t]/, "").strip }
94
+ .reject(&:empty?)
95
+ .last(lines)
96
+ rescue StandardError
97
+ []
98
+ end
99
+
100
+ def format_log_line(text)
101
+ text.length > LOG_LINE_MAX ? "#{text[0, LOG_LINE_MAX]}…" : text
102
+ end
103
+
104
+ def format_elapsed(seconds)
105
+ return "#{seconds}s" if seconds < 60
106
+
107
+ minutes = seconds / 60
108
+ return "#{minutes}m" if minutes < 60
109
+
110
+ "#{minutes / 60}h"
111
+ end
112
+
113
+ def format_context(card_key)
114
+ return "" unless card_key
115
+
116
+ if card_key.start_with?("discord-")
117
+ "Discord"
118
+ elsif card_key.start_with?("card-")
119
+ "##{card_key.split("-")[1]}"
120
+ else
121
+ card_key
122
+ end
123
+ end
124
+
125
+ def time_ago(iso_string)
126
+ return nil unless iso_string
127
+
128
+ seconds = (Time.now - Time.parse(iso_string)).to_i
129
+ "#{format_elapsed(seconds)} ago"
130
+ rescue StandardError
131
+ nil
132
+ end
133
+
134
+ def log_action(log_file)
135
+ return "" unless log_file
136
+
137
+ " | shell=#{LOG_VIEWER_PATH} param1=#{log_file} terminal=false refresh=false"
138
+ end
139
+
140
+ OPEN_SCRIPT = File.join(File.dirname(SELF_PATH), "open-action.sh")
141
+
142
+ def full_log_action(log_file)
143
+ return "" unless log_file
144
+
145
+ " | shell=#{OPEN_SCRIPT} param1=#{log_file.shellescape} terminal=false refresh=false"
146
+ end
147
+
148
+ def prompt_url(card_key)
149
+ return nil unless card_key
150
+
151
+ if card_key.start_with?("card-")
152
+ card_num = card_key.split("-")[1]
153
+ "https://app.fizzy.do/#{FIZZY_ACCOUNT_ID}/cards/#{card_num}" if FIZZY_ACCOUNT_ID && card_num
154
+ elsif card_key.start_with?("discord-") && DISCORD_GUILD_ID
155
+ parts = card_key.split("-")
156
+ # discord-AGENT-CHANNEL_ID-MESSAGE_ID (agent name may contain hyphens, IDs are last two numeric parts)
157
+ channel_id = parts[-2]
158
+ message_id = parts[-1]
159
+ "https://discord.com/channels/#{DISCORD_GUILD_ID}/#{channel_id}/#{message_id}" if channel_id && message_id
160
+ end
161
+ end
162
+
163
+ def prompt_action(card_key)
164
+ url = prompt_url(card_key)
165
+ return "" unless url
166
+
167
+ " | shell=#{OPEN_SCRIPT} param1=#{url} terminal=false refresh=false"
168
+ end
169
+
170
+ def worktree_path(log_file, card_key)
171
+ return nil unless log_file && card_key&.start_with?("card-")
172
+
173
+ dir = File.dirname(log_file, 2)
174
+ dir if File.directory?(dir) && dir != "/"
175
+ end
176
+
177
+ def worktree_action(log_file, card_key)
178
+ path = worktree_path(log_file, card_key)
179
+ return "" unless path
180
+
181
+ " | shell=#{OPEN_SCRIPT} param1=#{path.shellescape} terminal=false refresh=false"
182
+ end
183
+
184
+ COLOR_MAP = {
185
+ "red" => "#ff5555", "green" => "#50fa7b", "blue" => "#8be9fd",
186
+ "yellow" => "#f1fa8c", "cyan" => "#8be9fd", "magenta" => "#ff79c6",
187
+ "purple" => "#bd93f9", "pink" => "#ff79c6", "white" => "#f8f8f2"
188
+ }.freeze
189
+
190
+ def hex_color(name)
191
+ COLOR_MAP[name] || name
192
+ end
193
+
194
+ def generate_output
195
+ state = fetch_state
196
+ deployments = fetch_deployments
197
+
198
+ return ["⚠️", "---", state["error"], "---", "Refresh | refresh=true"].join("\n") if state["error"] && !deployments
199
+
200
+ sessions = state["sessions"] || []
201
+ recent = state["recent"] || []
202
+ lines = []
203
+
204
+ # Title line — agent emojis + deploy dots
205
+ parts = []
206
+ parts << sessions.map { |s| AGENTS.dig(s["agent"]&.downcase, :emoji) || DEFAULT_EMOJI }.join(" ") if sessions.any?
207
+ parts << deployments.map { |d| deploy_dot(d) }.join if deployments&.any?
208
+ title = parts.any? ? parts.join(" ") : "💤"
209
+ lines << title
210
+ lines << "---"
211
+
212
+ # Active sessions
213
+ if sessions.any?
214
+ lines << "Active | size=12"
215
+ sessions.each do |s|
216
+ agent_key = (s["agent"] || "").downcase
217
+ emoji = AGENTS.dig(agent_key, :emoji) || DEFAULT_EMOJI
218
+ color = AGENTS.dig(agent_key, :color)
219
+ color_str = color ? " color=#{hex_color(color)}" : ""
220
+ context = format_context(s["card_key"])
221
+ elapsed = format_elapsed(s["elapsed_seconds"] || 0)
222
+ lines << "#{emoji} #{s["agent"]}: #{context} (#{elapsed}) |#{color_str}"
223
+
224
+ tail_log(s["log_file"]).each do |line|
225
+ lines << "-- #{format_log_line(line)} | font=#{LOG_FONT} size=#{LOG_SIZE}"
226
+ end
227
+ lines << "-- ---" if s["log_file"]
228
+ lines << "-- Tail Log#{log_action(s["log_file"])}" if s["log_file"]
229
+ lines << "-- View Full Log#{full_log_action(s["log_file"])}" if s["log_file"]
230
+ lines << "-- Open Prompt#{prompt_action(s["card_key"])}" unless prompt_url(s["card_key"]).nil?
231
+ wt = worktree_path(s["log_file"], s["card_key"])
232
+ lines << "-- Open Worktree#{worktree_action(s["log_file"], s["card_key"])}" if wt
233
+ end
234
+ else
235
+ lines << "No active sessions | size=12"
236
+ end
237
+
238
+ # Recent completed sessions
239
+ if recent.any?
240
+ lines << "---"
241
+ lines << "Recent | size=12"
242
+ recent.each do |s|
243
+ agent_key = (s["agent"] || "").downcase
244
+ emoji = AGENTS.dig(agent_key, :emoji) || DEFAULT_EMOJI
245
+ context = format_context(s["card_key"])
246
+ ago = time_ago(s["finished_at"]) || "?"
247
+ lines << "#{emoji} #{s["agent"]}: #{context} — #{ago}"
248
+
249
+ tail_log(s["log_file"]).each do |line|
250
+ lines << "-- #{format_log_line(line)} | font=#{LOG_FONT} size=#{LOG_SIZE}"
251
+ end
252
+ lines << "-- ---" if s["log_file"]
253
+ lines << "-- Tail Log#{log_action(s["log_file"])}" if s["log_file"]
254
+ lines << "-- View Full Log#{full_log_action(s["log_file"])}" if s["log_file"]
255
+ lines << "-- Open Prompt#{prompt_action(s["card_key"])}" unless prompt_url(s["card_key"]).nil?
256
+ wt = worktree_path(s["log_file"], s["card_key"])
257
+ lines << "-- Open Worktree#{worktree_action(s["log_file"], s["card_key"])}" if wt
258
+ end
259
+ end
260
+
261
+ # Deployments
262
+ if deployments&.any?
263
+ lines << "---"
264
+ lines << "Deployments | size=12"
265
+ deployments.each do |d|
266
+ label = d["label"] || d["env"]
267
+ env = d["env"]
268
+ dot = deploy_dot(d)
269
+ if d["status"] == "occupied"
270
+ card = d["card_number"] ? "##{d["card_number"]}" : d["branch"] || "unknown"
271
+ ago = time_ago(d["deployed_at"])
272
+ status_label = case d["last_deploy_status"]
273
+ when "deploying" then " — deploying…"
274
+ when "failed" then " — FAILED"
275
+ else ""
276
+ end
277
+ line = "#{dot} #{label}: #{card}#{status_label}#{" (#{ago})" if ago}"
278
+ url = d["url"]
279
+ lines << (url ? "#{line} | href=#{url}" : line)
280
+ else
281
+ ago = time_ago(d["cleared_at"])
282
+ last = d["last_card"] ? " (was ##{d["last_card"]})" : ""
283
+ lines << "#{dot} #{label}: Available#{" #{ago}" if ago}#{last}"
284
+ end
285
+ lines << "-- Deploy to #{label} | shell=#{DEPLOY_SCRIPT_PATH} param1=#{env} terminal=false refresh=true"
286
+ lines << "-- Open #{label} | shell=#{OPEN_SCRIPT} param1=#{d["url"]} terminal=false refresh=false" if d["status"] == "occupied" && d["url"]
287
+ end
288
+ end
289
+
290
+ lines << "---"
291
+ lines << "Refresh | refresh=true"
292
+ lines.join("\n")
293
+ end
294
+
295
+ puts generate_output
@@ -0,0 +1,15 @@
1
+ #!/bin/bash
2
+ # Opens a URL in browser, a directory in Finder, or a file in the default viewer.
3
+ # Used by the SwiftBar menubar plugin for prompt links, worktrees, and full log views.
4
+
5
+ arg="$1"
6
+
7
+ if [[ "$arg" == https://discord.com/* ]]; then
8
+ open "discord://${arg#https://}"
9
+ elif [[ "$arg" == http* ]]; then
10
+ open "$arg"
11
+ elif [[ -d "$arg" ]]; then
12
+ open -a Kiro "$arg"
13
+ elif [[ -f "$arg" ]]; then
14
+ open -a "Console" "$arg" 2>/dev/null || open "$arg"
15
+ fi