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,78 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # One-time setup script to install ZillaCore menubar plugin into xbar or SwiftBar
5
+ # Run this once — the plugin will then auto-refresh on its configured interval
6
+
7
+ PLUGIN_APPS = [
8
+ {
9
+ name: "SwiftBar",
10
+ plugin_dir: File.expand_path("~/Library/Application Support/SwiftBar/Plugins"),
11
+ app_path: "/Applications/SwiftBar.app"
12
+ },
13
+ {
14
+ name: "xbar",
15
+ plugin_dir: File.expand_path("~/Library/Application Support/xbar/plugins"),
16
+ app_path: "/Applications/xbar.app"
17
+ }
18
+ ].freeze
19
+
20
+ SYMLINK_NAME = "zillacore.2s.rb"
21
+ SOURCE_PATH = File.join(File.dirname(File.expand_path(__FILE__)), "menubar.rb")
22
+
23
+ def detect_plugin_app
24
+ PLUGIN_APPS.each do |app|
25
+ return { name: app[:name], plugin_dir: app[:plugin_dir] } if Dir.exist?(app[:plugin_dir]) || File.exist?(app[:app_path])
26
+ end
27
+ nil
28
+ end
29
+
30
+ def install_plugin(plugin_dir, source_path)
31
+ FileUtils.mkdir_p(plugin_dir)
32
+ link_path = File.join(plugin_dir, SYMLINK_NAME)
33
+
34
+ # Remove existing symlink/file if present
35
+ File.delete(link_path) if File.exist?(link_path) || File.symlink?(link_path)
36
+
37
+ File.symlink(source_path, link_path)
38
+ rescue StandardError => e
39
+ warn "✗ Failed to create symlink: #{e.message}"
40
+ warn " Source: #{source_path}"
41
+ warn " Target: #{link_path}"
42
+ exit 1
43
+ end
44
+
45
+ def verify_executable!(path) # rubocop:disable Naming/PredicateMethod
46
+ unless File.executable?(path)
47
+ File.chmod(0o755, path)
48
+ warn " Fixed executable permission on #{path}"
49
+ end
50
+ File.executable?(path)
51
+ end
52
+
53
+ # --- Main ---
54
+
55
+ require "fileutils"
56
+
57
+ app = detect_plugin_app
58
+
59
+ unless app
60
+ puts "No xbar or SwiftBar installation detected."
61
+ puts ""
62
+ puts "Install one of the following to use the ZillaCore menu bar plugin:"
63
+ puts " • xbar: https://xbarapp.com"
64
+ puts " • SwiftBar: https://github.com/swiftbar/SwiftBar"
65
+ puts ""
66
+ puts "After installing, re-run this script:"
67
+ puts " ruby #{__FILE__}"
68
+ exit 0
69
+ end
70
+
71
+ puts "Detected #{app[:name]}"
72
+ install_plugin(app[:plugin_dir], SOURCE_PATH)
73
+ verify_executable!(SOURCE_PATH)
74
+
75
+ link_path = File.join(app[:plugin_dir], SYMLINK_NAME)
76
+ puts "✓ Installed ZillaCore plugin into #{app[:name]}"
77
+ puts " Symlink: #{link_path} → #{SOURCE_PATH}"
78
+ puts " Refresh interval: 2s"
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # One-time setup: replaces the single zillacore-deployments module
5
+ # with per-environment modules so each dot gets its own border/click.
6
+
7
+ require "json"
8
+ require "fileutils"
9
+
10
+ WAYBAR_CONFIG = File.expand_path("~/.config/waybar/config.jsonc")
11
+ DEPLOY_SCRIPT = File.expand_path("~/.zillacore/bin/waybar-deploy-env")
12
+ DEPLOYMENTS_CONFIG = File.expand_path("~/.zillacore/deployments.json")
13
+ WAYBAR_STYLE = File.expand_path("~/.config/waybar/style.css")
14
+
15
+ # Create wrapper script that resolves from server.root
16
+ wrapper_dir = File.expand_path("~/.zillacore/bin")
17
+ FileUtils.mkdir_p(wrapper_dir)
18
+ File.write(DEPLOY_SCRIPT, <<~SCRIPT)
19
+ #!/usr/bin/env ruby
20
+ root_file = File.expand_path("~/.zillacore/server.root")
21
+ if File.exist?(root_file)
22
+ server_root = File.read(root_file).strip
23
+ script = File.join(server_root, "monitor", "waybar-deploy-env.rb")
24
+ if File.exist?(script)
25
+ ARGV.unshift if ARGV.empty?
26
+ load script
27
+ exit
28
+ end
29
+ end
30
+ require "json"
31
+ puts({ text: "", tooltip: "ZillaCore server root not found", class: "error" }.to_json)
32
+ SCRIPT
33
+ File.chmod(0o755, DEPLOY_SCRIPT)
34
+
35
+ def load_config
36
+ content = File.read(WAYBAR_CONFIG)
37
+ json_content = content.lines.reject { |line| line.strip.start_with?("//") }.join
38
+ JSON.parse(json_content)
39
+ end
40
+
41
+ def save_config(config)
42
+ File.write(WAYBAR_CONFIG, JSON.pretty_generate(config))
43
+ end
44
+
45
+ deployments = JSON.parse(File.read(DEPLOYMENTS_CONFIG))
46
+ envs = deployments["environments"].keys
47
+
48
+ config = load_config
49
+
50
+ # Remove old single deployments module from all bar positions
51
+ %w[modules-left modules-center modules-right].each do |pos|
52
+ next unless config[pos]
53
+
54
+ config[pos].reject! { |m| m.to_s.include?("zillacore-deploy") }
55
+ end
56
+ config.delete("custom/zillacore-deployments")
57
+
58
+ # Remove any existing per-env modules
59
+ config.each_key do |key|
60
+ config.delete(key) if key.start_with?("custom/zillacore-deploy-")
61
+ end
62
+
63
+ # Insert per-env modules into modules-center, before custom/zillacore
64
+ center = config["modules-center"] || []
65
+ zc_idx = center.index("custom/zillacore") || center.length
66
+ envs.each_with_index do |env, i|
67
+ mod_name = "custom/zillacore-deploy-#{env}"
68
+ center.insert(zc_idx + i, mod_name) unless center.include?(mod_name)
69
+ end
70
+ config["modules-center"] = center
71
+
72
+ # Add module configs for each env
73
+ envs.each do |env|
74
+ mod_name = "custom/zillacore-deploy-#{env}"
75
+ config[mod_name] = {
76
+ "exec" => "#{DEPLOY_SCRIPT} #{env}",
77
+ "return-type" => "json",
78
+ "interval" => 30,
79
+ "format" => "{}",
80
+ "tooltip" => true,
81
+ "escape" => false,
82
+ "on-click" => "#{DEPLOY_SCRIPT} #{env} --click",
83
+ "on-click-right" => "#{DEPLOY_SCRIPT} #{env} --deploy"
84
+ }
85
+ end
86
+
87
+ save_config(config)
88
+ puts "✓ Added per-environment deploy modules: #{envs.map { |e| "custom/zillacore-deploy-#{e}" }.join(", ")}"
89
+
90
+ # Update CSS — remove old single-module styles, add per-env styles
91
+ style = File.read(WAYBAR_STYLE)
92
+
93
+ # Remove old block
94
+ style.gsub!(%r{/\* ZillaCore deployment environment dots \*/.*?(?=\n\n|\n/\*|\z)}m, "")
95
+ style.gsub!(/\n*#custom-zillacore-deployments[^{]*\{[^}]*\}\n*/m, "")
96
+
97
+ # Add new per-env styles
98
+ unless style.include?("#custom-zillacore-deploy-")
99
+ css = <<~CSS
100
+
101
+ /* ZillaCore per-environment deploy dots */
102
+ [id^="custom-zillacore-deploy-"] {
103
+ font-size: 28px;
104
+ padding: 0 6px;
105
+ border-radius: 8px;
106
+ border: 2px solid transparent;
107
+ }
108
+
109
+ [id^="custom-zillacore-deploy-"].deploy-recent {
110
+ border: 2px solid #4488ff;
111
+ }
112
+
113
+ [id^="custom-zillacore-deploy-"].deploy-failed {
114
+ border: 2px solid #ff4444;
115
+ }
116
+ CSS
117
+ File.write(WAYBAR_STYLE, "#{style.strip}\n#{css}")
118
+ puts "✓ Updated waybar CSS with per-environment border styles"
119
+ end
120
+
121
+ puts "✓ Restart waybar to apply: omarchy restart waybar"
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # One-time setup: adds ZillaCore deployments module to waybar config
5
+ # and repositions the agent session module with more breathing room.
6
+
7
+ require "json"
8
+
9
+ WAYBAR_CONFIG = File.expand_path("~/.config/waybar/config.jsonc")
10
+ DEPLOY_SCRIPT = File.expand_path("~/Code/zillacore/monitor/waybar-deployments.rb")
11
+ WAYBAR_STYLE = File.expand_path("~/.config/waybar/style.css")
12
+
13
+ def load_config
14
+ content = File.read(WAYBAR_CONFIG)
15
+ json_content = content.lines.reject { |line| line.strip.start_with?("//") }.join
16
+ JSON.parse(json_content)
17
+ end
18
+
19
+ def save_config(config)
20
+ File.write(WAYBAR_CONFIG, JSON.pretty_generate(config))
21
+ end
22
+
23
+ config = load_config
24
+
25
+ # Remove any existing deployment module
26
+ config["modules-center"]&.reject! { |m| m.to_s.include?("zillacore-deploy") }
27
+ config["modules-right"]&.reject! { |m| m.to_s.include?("zillacore-deploy") }
28
+ config.delete("custom/zillacore-deployments")
29
+
30
+ # Move agent session module from modules-right to modules-center (after indicators)
31
+ if config["modules-right"]&.delete("custom/zillacore")
32
+ config["modules-center"] ||= []
33
+ config["modules-center"] << "custom/zillacore" unless config["modules-center"].include?("custom/zillacore")
34
+ end
35
+
36
+ # Add deployments module right before agent sessions in modules-center
37
+ center = config["modules-center"] || []
38
+ zc_idx = center.index("custom/zillacore")
39
+ if zc_idx
40
+ center.insert(zc_idx, "custom/zillacore-deployments") unless center.include?("custom/zillacore-deployments")
41
+ else
42
+ center << "custom/zillacore-deployments" unless center.include?("custom/zillacore-deployments")
43
+ end
44
+
45
+ # Add module config
46
+ config["custom/zillacore-deployments"] = {
47
+ "exec" => DEPLOY_SCRIPT,
48
+ "return-type" => "json",
49
+ "interval" => 30,
50
+ "format" => "{}",
51
+ "tooltip" => true,
52
+ "format-alt" => "{}",
53
+ "escape" => false,
54
+ "on-click" => "#{DEPLOY_SCRIPT} --click",
55
+ "on-click-right" => "#{DEPLOY_SCRIPT} --deploy"
56
+ }
57
+
58
+ save_config(config)
59
+
60
+ # Add CSS for the deployments module
61
+ style = File.read(WAYBAR_STYLE)
62
+ unless style.include?("#custom-zillacore-deployments")
63
+ css = <<~CSS
64
+
65
+ /* ZillaCore deployment environment dots */
66
+ #custom-zillacore-deployments {
67
+ margin-left: 100px;
68
+ margin-right: 40px;
69
+ font-size: 14px;
70
+ }
71
+ CSS
72
+ File.write(WAYBAR_STYLE, style + css)
73
+ puts "✓ Added deployment styles to waybar CSS"
74
+ end
75
+
76
+ # Add padding-right to agent sessions module
77
+ unless style.include?("padding-right") && style.include?("#custom-zillacore")
78
+ updated_style = File.read(WAYBAR_STYLE)
79
+ if updated_style.include?("#custom-zillacore {")
80
+ updated_style.sub!(/(#custom-zillacore\s*\{[^}]*)(\})/) do
81
+ block = Regexp.last_match(1)
82
+ close = Regexp.last_match(2)
83
+ if block.include?("padding-right")
84
+ "#{block}#{close}"
85
+ else
86
+ "#{block}\n padding-right: 100px;\n#{close}"
87
+ end
88
+ end
89
+ File.write(WAYBAR_STYLE, updated_style)
90
+ puts "✓ Added padding-right to agent session module"
91
+ end
92
+ end
93
+
94
+ puts "✓ Deployments module added to waybar config"
95
+ puts " Positioned: [deploy-dots] [agent-sessions] in center bar"
96
+ puts " Restart waybar to apply: omarchy restart waybar"
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # One-time setup script to add ZillaCore 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("~/.zillacore/bin/waybar-status")
12
+
13
+ # Create a wrapper script that resolves the running server's waybar.rb dynamically
14
+ wrapper_dir = File.expand_path("~/.zillacore/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 ZillaCore server's waybar module dynamically.
20
+ # This allows worktrees / branches to work without reconfiguring waybar.
21
+
22
+ root_file = File.expand_path("~/.zillacore/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: "ZillaCore Error: HTTP \#{response.code}", class: "error" }.to_json)
49
+ end
50
+ rescue StandardError => e
51
+ puts({ text: "⚠️", tooltip: "ZillaCore 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 zillacore 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/zillacore", "group/zillacore-agents"].include?(m.to_s) }
75
+ end
76
+ config.each_key do |key|
77
+ config.delete(key) if ["custom/zillacore", "group/zillacore-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/zillacore")
83
+
84
+ # Add module config
85
+ config["custom/zillacore"] = {
86
+ "exec" => WAYBAR_SCRIPT,
87
+ "return-type" => "json",
88
+ "interval" => 3,
89
+ "format" => "{}",
90
+ "tooltip" => true,
91
+ "on-click" => File.expand_path("~/.zillacore/bin/waybar-logs").to_s
92
+ }
93
+
94
+ # Create on-click wrapper too
95
+ logs_wrapper = File.expand_path("~/.zillacore/bin/waybar-logs")
96
+ File.write(logs_wrapper, <<~SCRIPT)
97
+ #!/usr/bin/env ruby
98
+ root_file = File.expand_path("~/.zillacore/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 "ZillaCore server root not found"
105
+ SCRIPT
106
+ File.chmod(0o755, logs_wrapper)
107
+
108
+ # Save updated config
109
+ save_config(config)
110
+
111
+ puts "✓ ZillaCore 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 ZillaCore 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, "zillacore.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 "✓ ZillaCore 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
+ # ZillaCore macOS Log Viewer
5
+ # Opens agent log files in a terminal (wezterm by default, configurable via waybar.json)
6
+ # Set "log_viewer_command" in ~/.zillacore/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/zillacore-monitor.sock"
13
+ CONFIG_PATH = File.expand_path("~/.zillacore/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("ZillaCore", "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("ZillaCore", state["error"])
162
+ exit 1
163
+ end
164
+
165
+ if sessions.empty?
166
+ notify("ZillaCore", "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("ZillaCore", "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("ZillaCore", "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("ZillaCore", "Log file not found: #{log_file}")
209
+ end
210
+ end