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,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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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
@@ -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