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,1643 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Discord bot handlers: per-agent gateway connections, message handling, API helpers.
4
+ #
5
+ # Each agent with a `discord_bot_token` in the agent registry gets its own
6
+ # Discord bot connection. Users @mention @Galen or @GLaDOS directly in Discord
7
+ # rather than a single shared bot.
8
+
9
+ require "English"
10
+ DISCORD_CONFIG_FILE = File.join(ZILLACORE_DIR, "discord.json")
11
+ DISCORD_API_BASE = "https://discord.com/api/v10"
12
+ DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
13
+
14
+ # Draft/posted directories for resilient Discord response delivery.
15
+ # Response files land in draft/ with a .meta.json sidecar containing delivery info.
16
+ # After successful posting, both files move to posted/.
17
+ # A poller thread recovers orphaned drafts (e.g. after a server restart).
18
+ DISCORD_DRAFT_DIR = File.join(ZILLACORE_DIR, "tmp", "discord", "draft")
19
+ DISCORD_POSTED_DIR = File.join(ZILLACORE_DIR, "tmp", "discord", "posted")
20
+ FileUtils.mkdir_p(DISCORD_DRAFT_DIR)
21
+ FileUtils.mkdir_p(DISCORD_POSTED_DIR)
22
+
23
+ # Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
24
+ DISCORD_BOTS = {}
25
+ DISCORD_BOTS_MUTEX = Mutex.new
26
+ DISCORD_ALL_READY_LOGGED = { done: false }
27
+
28
+ # Shared thread map: when multiple agents are mentioned in the same message,
29
+ # the first to deliver creates the thread and stores its ID here so the rest
30
+ # post into the same thread instead of creating duplicates.
31
+ # Key: original message_id, Value: thread channel ID
32
+ DISCORD_SHARED_THREADS = {}
33
+ DISCORD_SHARED_THREADS_MUTEX = Mutex.new
34
+
35
+ # Zillacore restart queue: when an agent works on zillacore itself, queue a restart
36
+ # instead of doing it immediately. A background thread checks every 30s and only
37
+ # restarts when no other agents are running, preventing mid-session kills.
38
+ # Using a hash instead of a constant to allow mutation inside synchronize blocks
39
+ ZILLACORE_RESTART_STATE = { queued: false, triggered_by: nil }
40
+ ZILLACORE_RESTART_MUTEX = Mutex.new
41
+
42
+ def queue_zillacore_restart(agent_name)
43
+ ZILLACORE_RESTART_MUTEX.synchronize do
44
+ unless ZILLACORE_RESTART_STATE[:queued]
45
+ ZILLACORE_RESTART_STATE[:queued] = true
46
+ ZILLACORE_RESTART_STATE[:triggered_by] = agent_name
47
+ LOG.info "[ZillaCore] #{agent_name} queued a restart — will execute when all agents finish"
48
+ end
49
+ end
50
+ end
51
+
52
+ # Send a Discord notification about zillacore restart/startup using any available bot token.
53
+ def send_restart_notification(message)
54
+ channel_id = DISCORD_CONFIG["notification_channel_id"]
55
+ return unless channel_id
56
+
57
+ tokens = discord_bot_tokens
58
+ # Prefer the triggering agent's token, fall back to first available
59
+ triggered_by = ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:triggered_by] }
60
+ token = tokens[triggered_by&.downcase] || tokens.values.first
61
+ return unless token
62
+
63
+ send_discord_message(channel_id, message, token: token)
64
+ rescue StandardError => e
65
+ LOG.warn "[ZillaCore] Failed to send restart notification: #{e.message}"
66
+ end
67
+
68
+ def any_agents_running?
69
+ ACTIVE_SESSIONS_MUTEX.synchronize do
70
+ ACTIVE_SESSIONS.any? do |_key, info|
71
+ Process.kill(0, info[:pid])
72
+ true
73
+ rescue Errno::ESRCH, Errno::EPERM
74
+ false
75
+ end
76
+ end
77
+ end
78
+
79
+ def start_zillacore_restart_monitor
80
+ Thread.new do
81
+ LOG.info "[ZillaCore] Restart monitor started, checking every 30s"
82
+ loop do
83
+ sleep 30
84
+ restart_needed = ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:queued] }
85
+
86
+ if restart_needed && !any_agents_running?
87
+ triggered_by = ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:triggered_by] }
88
+ LOG.info "[ZillaCore] All agents finished, executing restart..."
89
+ ZILLACORE_RESTART_MUTEX.synchronize { ZILLACORE_RESTART_STATE[:queued] = false }
90
+
91
+ send_restart_notification("🔄 Restarting zillacore (triggered by #{triggered_by || "unknown"})...")
92
+
93
+ # Schedule restart: stop now, start in 3 seconds
94
+ # This ensures the current process fully exits before the new one starts
95
+ Thread.new do
96
+ sleep 1 # Give time for log to flush
97
+
98
+ # Spawn a delayed restart command that will execute after we exit
99
+ # Inherit current PATH so zillacore binary can be found regardless of install location
100
+ # Process.detach ensures the spawned process survives when parent exits
101
+ pid = spawn({ "PATH" => ENV.fetch("PATH", nil) }, "sh", "-c", "sleep 3 && zillacore server --daemon",
102
+ out: "/dev/null", err: "/dev/null")
103
+ Process.detach(pid)
104
+
105
+ sleep 1
106
+ LOG.info "[ZillaCore] Stopping server, new instance will start in 3 seconds..."
107
+ Sinatra::Application.quit!
108
+ sleep 0.5 # Give Sinatra a moment to shut down gracefully
109
+ exit! # Force exit to kill all threads immediately
110
+ end
111
+ elsif restart_needed
112
+ active_count = ACTIVE_SESSIONS_MUTEX.synchronize { ACTIVE_SESSIONS.size }
113
+ LOG.info "[ZillaCore] Restart queued but #{active_count} agent(s) still running, waiting..."
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ def load_discord_config
120
+ default = { "channel_mappings" => {}, "authorized_role_ids" => [], "authorized_user_ids" => [] }
121
+ return default unless File.exist?(DISCORD_CONFIG_FILE)
122
+
123
+ JSON.parse(File.read(DISCORD_CONFIG_FILE))
124
+ rescue JSON::ParserError => e
125
+ LOG.error "Failed to parse discord config: #{e.message}"
126
+ default
127
+ end
128
+
129
+ DISCORD_CONFIG = load_discord_config
130
+
131
+ def reload_discord_config!
132
+ DISCORD_CONFIG.replace(load_discord_config)
133
+ end
134
+
135
+ # Collect all agent Discord bot tokens from the registry.
136
+ # Returns { "galen" => "token...", "glados" => "token..." }
137
+ def discord_bot_tokens
138
+ tokens = {}
139
+ AGENT_REGISTRY.each do |key, entry|
140
+ next unless entry.is_a?(Hash)
141
+
142
+ token = (entry["env"] || {})["DISCORD_BOT_TOKEN"]
143
+ next unless token
144
+
145
+ tokens[key] = token
146
+ end
147
+ tokens
148
+ end
149
+
150
+ # --- Discord REST API ---
151
+
152
+ def discord_api(method, path, token:, body: nil, log_errors: true)
153
+ uri = URI("#{DISCORD_API_BASE}#{path}")
154
+ http = Net::HTTP.new(uri.host, uri.port)
155
+ http.use_ssl = true
156
+
157
+ req = case method
158
+ when :get then Net::HTTP::Get.new(uri)
159
+ when :post then Net::HTTP::Post.new(uri)
160
+ when :put then Net::HTTP::Put.new(uri)
161
+ when :delete then Net::HTTP::Delete.new(uri)
162
+ end
163
+
164
+ req["Authorization"] = "Bot #{token}"
165
+ req["Content-Type"] = "application/json"
166
+ req.body = body.to_json if body
167
+
168
+ response = http.request(req)
169
+
170
+ if response.code.to_i == 429
171
+ retry_after = JSON.parse(response.body)["retry_after"] || 1
172
+ LOG.warn "Discord rate limited, waiting #{retry_after}s"
173
+ sleep retry_after
174
+ return discord_api(method, path, token: token, body: body, log_errors: log_errors)
175
+ end
176
+
177
+ LOG.error "Discord API error (#{method} #{path}): HTTP #{response.code} - #{response.body}" if response.code.to_i >= 400 && log_errors
178
+
179
+ JSON.parse(response.body) unless response.body.nil? || response.body.empty?
180
+ rescue StandardError => e
181
+ LOG.error "Discord API error (#{method} #{path}): #{e.message}" if log_errors
182
+ nil
183
+ end
184
+
185
+ def fetch_discord_channel_history(channel_id, before_message_id, token:, limit: 10)
186
+ messages = discord_api(:get, "/channels/#{channel_id}/messages?before=#{before_message_id}&limit=#{limit}", token: token)
187
+
188
+ all_messages = messages.is_a?(Array) ? messages : []
189
+
190
+ # If we're in a thread, check if the oldest message is a THREAD_STARTER_MESSAGE (type 21).
191
+ # These messages have no content but point to the original message via referenced_message.
192
+ # We need to include that original message for full context.
193
+ if all_messages.any?
194
+ oldest = all_messages.last # API returns newest-first
195
+ if oldest && oldest["type"] == 21 && oldest["referenced_message"]
196
+ # Prepend the actual starter message content
197
+ all_messages << oldest["referenced_message"]
198
+ end
199
+ end
200
+
201
+ return "" if all_messages.empty?
202
+
203
+ # Messages come newest-first from the API, reverse for chronological order
204
+ lines = all_messages.reverse.filter_map do |msg|
205
+ author = msg.dig("author", "username") || "unknown"
206
+ content = msg["content"]&.strip || ""
207
+ next if content.empty?
208
+
209
+ "#{author}: #{content}"
210
+ end
211
+
212
+ return "" if lines.empty?
213
+
214
+ lines.join("\n")
215
+ rescue StandardError => e
216
+ LOG.warn "Failed to fetch channel history: #{e.message}"
217
+ ""
218
+ end
219
+
220
+ def fetch_channel_info(channel_id, token:)
221
+ discord_api(:get, "/channels/#{channel_id}", token: token)
222
+ end
223
+
224
+ def forum_channel?(channel_id, token:)
225
+ info = fetch_channel_info(channel_id, token: token)
226
+ info && info["type"] == 15
227
+ end
228
+
229
+ def find_latest_forum_thread(channel_id, token:)
230
+ # Get the guild ID from the channel info, then list active threads
231
+ channel_info = fetch_channel_info(channel_id, token: token)
232
+ return nil unless channel_info && channel_info["guild_id"]
233
+
234
+ guild_id = channel_info["guild_id"]
235
+ result = discord_api(:get, "/guilds/#{guild_id}/threads/active", token: token)
236
+ return nil unless result && result["threads"]
237
+
238
+ # Filter to threads in this forum channel, sort by creation (newest first)
239
+ forum_threads = result["threads"]
240
+ .select { |t| t["parent_id"] == channel_id }
241
+ .sort_by { |t| t["id"].to_i }
242
+ .reverse
243
+
244
+ return nil if forum_threads.empty?
245
+
246
+ latest = forum_threads.first
247
+ LOG.info "[Discord] Found latest forum thread: #{latest["id"]} (#{latest["name"]}) in channel #{channel_id}"
248
+ latest
249
+ end
250
+
251
+ def create_forum_post(channel_id, title:, content:, token:)
252
+ thread_name = title.length > 100 ? "#{title[0..96]}..." : title
253
+ result = discord_api(:post, "/channels/#{channel_id}/threads", token: token, body: {
254
+ name: thread_name,
255
+ message: { content: content },
256
+ auto_archive_duration: 1440
257
+ })
258
+ if result && result["id"]
259
+ LOG.info "[Discord] Forum post created in channel #{channel_id}, thread_id: #{result["id"]}"
260
+ else
261
+ LOG.error "[Discord] Failed to create forum post in channel #{channel_id}, result: #{result.inspect}"
262
+ end
263
+ result
264
+ end
265
+
266
+ def send_discord_message(channel_id, content, token:, reply_to: nil)
267
+ body = { content: content }
268
+ body[:message_reference] = { message_id: reply_to } if reply_to
269
+ result = discord_api(:post, "/channels/#{channel_id}/messages", token: token, body: body)
270
+ if result && result["id"]
271
+ LOG.info "[Discord] Message posted successfully to channel #{channel_id}, message_id: #{result["id"]}"
272
+ else
273
+ LOG.error "[Discord] Failed to post message to channel #{channel_id}, result: #{result.inspect}"
274
+ end
275
+ result
276
+ end
277
+
278
+ def send_discord_typing(channel_id, token:)
279
+ discord_api(:post, "/channels/#{channel_id}/typing", token: token)
280
+ end
281
+
282
+ def fetch_discord_message(channel_id, message_id, token:, log_errors: true)
283
+ discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: token, log_errors: log_errors)
284
+ end
285
+
286
+ # Emojis reserved for zillacore functionality — not treated as feedback
287
+ RESERVED_EMOJIS = %w[👀 ❌ 🛑 🚫 ⚠️ ⏳ 😶 ❔ ❓ 🧠].freeze
288
+
289
+ def add_discord_reaction(channel_id, message_id, emoji, token:)
290
+ encoded = URI.encode_www_form_component(emoji)
291
+ discord_api(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
292
+ end
293
+
294
+ def remove_discord_reaction(channel_id, message_id, emoji, token:)
295
+ encoded = URI.encode_www_form_component(emoji)
296
+ discord_api(:delete, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", token: token)
297
+ end
298
+
299
+ def create_discord_thread(channel_id, message_id, name:, token:)
300
+ thread_name = name.length > 100 ? "#{name[0..96]}..." : name
301
+ discord_api(:post, "/channels/#{channel_id}/messages/#{message_id}/threads", token: token, body: {
302
+ name: thread_name,
303
+ auto_archive_duration: 1440
304
+ })
305
+ end
306
+
307
+ def fetch_guild_member(guild_id, user_id, token:)
308
+ discord_api(:get, "/guilds/#{guild_id}/members/#{user_id}", token: token)
309
+ end
310
+
311
+ def send_long_discord_message(channel_id, content, token:, reply_to: nil)
312
+ if content.length <= 2000
313
+ send_discord_message(channel_id, content, token: token, reply_to: reply_to)
314
+ return
315
+ end
316
+
317
+ chunks = []
318
+ remaining = content
319
+ while remaining.length.positive?
320
+ if remaining.length <= 2000
321
+ chunks << remaining
322
+ remaining = ""
323
+ else
324
+ split_at = remaining.rindex("\n", 1990) || 1990
325
+ chunks << remaining[0...split_at]
326
+ remaining = remaining[split_at..].lstrip
327
+ end
328
+ end
329
+
330
+ chunks.each_with_index do |chunk, i|
331
+ send_discord_message(channel_id, chunk, token: token, reply_to: i.zero? ? reply_to : nil)
332
+ sleep 0.5
333
+ end
334
+ end
335
+
336
+ def find_project_for_discord_channel(channel_id)
337
+ mapping = DISCORD_CONFIG.dig("channel_mappings", channel_id)
338
+
339
+ unless mapping
340
+ default_project = DISCORD_CONFIG["default_project"]
341
+ mapping = { "project" => default_project } if default_project
342
+ end
343
+
344
+ return nil unless mapping
345
+
346
+ project_key = mapping["project"]
347
+ project_config = PROJECTS[project_key]
348
+ return nil unless project_config
349
+
350
+ [project_key, project_config, mapping]
351
+ end
352
+
353
+ # Find the root message for a conversation thread.
354
+ # Walks back through message_reference chain to find the original message.
355
+ # Returns { id: root_message_id, content: root_message_text, author: username }
356
+ # or { id: current_message_id, content: nil, author: nil } if already the root.
357
+ def find_root_message(message, channel_id, bot_token)
358
+ current_msg = message
359
+ visited = Set.new
360
+ max_depth = 20 # Prevent infinite loops
361
+ walked = false
362
+
363
+ max_depth.times do
364
+ msg_id = current_msg["id"]
365
+ return { id: msg_id, content: nil, author: nil } if visited.include?(msg_id) # Loop detected
366
+
367
+ visited << msg_id
368
+
369
+ ref = current_msg["message_reference"]
370
+ break unless ref
371
+
372
+ ref_msg_id = ref["message_id"]
373
+ ref_channel = ref["channel_id"] || channel_id
374
+ break unless ref_msg_id
375
+
376
+ # Fetch the referenced message
377
+ referenced = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
378
+ break unless referenced
379
+
380
+ current_msg = referenced
381
+ walked = true
382
+ end
383
+
384
+ {
385
+ id: current_msg["id"],
386
+ content: walked ? current_msg["content"]&.strip : nil,
387
+ author: walked ? current_msg.dig("author", "username") : nil
388
+ }
389
+ end
390
+
391
+ # Build a Discord mention roster so the agent can @mention people and other bots.
392
+ # Discord requires `<@USER_ID>` syntax — plain text "@Name" doesn't work.
393
+ # Sources:
394
+ # - Other agent bots: DISCORD_BOTS (populated at gateway READY)
395
+ # - Human users: discord.json "user_mappings" (manually maintained)
396
+ def discord_mention_roster
397
+ lines = []
398
+
399
+ # Agent bots
400
+ DISCORD_BOTS_MUTEX.synchronize do
401
+ DISCORD_BOTS.each do |agent_key, info|
402
+ next unless info[:user_id]
403
+
404
+ display = fizzy_display_name(agent_key) || agent_key.capitalize
405
+ lines << " - #{display}: `<@#{info[:user_id]}>`"
406
+ end
407
+ end
408
+
409
+ # Human users from config
410
+ user_mappings = DISCORD_CONFIG["user_mappings"] || {}
411
+ user_mappings.each do |name, discord_id|
412
+ lines << " - #{name}: `<@#{discord_id}>`"
413
+ end
414
+
415
+ lines.join("\n")
416
+ end
417
+
418
+ # Handle an incoming Discord message for a specific agent bot.
419
+ # agent_key: the lowercase agent key (e.g. "galen")
420
+ # bot_token: the Discord bot token for this agent
421
+ # bot_user_id: the Discord user ID of this bot
422
+ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
423
+ channel_id = message["channel_id"]
424
+ message_id = message["id"]
425
+ author = message["author"]
426
+ content = message["content"] || ""
427
+
428
+ is_bot = !author["bot"].nil?
429
+
430
+ # Identify if the author is a known agent bot (local or remote).
431
+ # Local agents are in DISCORD_BOTS; remote agents (running on other machines)
432
+ # are recognized via discord.json "user_mappings".
433
+ sender_agent_key = nil
434
+ if is_bot
435
+ sender_id = author["id"]
436
+
437
+ # Check local bots first
438
+ DISCORD_BOTS_MUTEX.synchronize do
439
+ DISCORD_BOTS.each do |key, info|
440
+ if info[:user_id] == sender_id && key != agent_key
441
+ sender_agent_key = key
442
+ break
443
+ end
444
+ end
445
+ end
446
+
447
+ # Check user_mappings for remote agents
448
+ unless sender_agent_key
449
+ user_mappings = DISCORD_CONFIG["user_mappings"] || {}
450
+ user_mappings.each do |name, discord_id|
451
+ if discord_id == sender_id
452
+ sender_agent_key = name.downcase
453
+ break
454
+ end
455
+ end
456
+ end
457
+
458
+ # Unknown bot or self — ignore entirely
459
+ unless sender_agent_key
460
+ LOG.info "[Discord:#{agent_key}] Ignoring unknown bot: id=#{sender_id}, username=#{author["username"]}, bot=#{author["bot"]}"
461
+ return
462
+ end
463
+ end
464
+
465
+ mentions = message["mentions"] || []
466
+ mentioned = mentions.any? { |m| m["id"].to_s == bot_user_id.to_s }
467
+
468
+ # Discord doesn't always populate the mentions array for bot-to-bot mentions.
469
+ # Check the raw content for mention patterns as a fallback.
470
+ mentioned ||= content.match?(/<@!?#{Regexp.escape(bot_user_id.to_s)}>/)
471
+
472
+ # Check for @everyone mention (DISABLED — agents need to cool it)
473
+ # unless mentioned
474
+ # mentioned = message['mention_everyone'] == true
475
+ # end
476
+
477
+ # Cross-agent bot mention: only proceed if this bot is explicitly @mentioned
478
+ # and the dispatch depth hasn't been exceeded (prevents infinite loops).
479
+ if sender_agent_key
480
+ unless mentioned
481
+ fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
482
+ agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
483
+ # LOG.info "[Discord:#{agent_display}] Ignoring cross-agent message from #{sender_display} — not mentioned (content: #{content[0..100]})"
484
+ return
485
+ end
486
+
487
+ # Skip dispatch when the message is a Fizzy card creation/assignment
488
+ # announcement. The Fizzy webhook handles card assignments — dispatching
489
+ # here too causes the mentioned agent to respond in Discord instead of
490
+ # (or in addition to) the new card.
491
+ if content.match?(/created\s+card\s+#?\d+/i) || content.match?(/assigned\s+.*card\s+#?\d+/i) || content.match?(/card\s+#?\d+.*assigned/i)
492
+ sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
493
+ agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
494
+ LOG.info "[Discord:#{agent_display}] Ignoring cross-agent mention from #{sender_display} — Fizzy card creation/assignment (handled by webhook)"
495
+ return
496
+ end
497
+
498
+ depth_key = "discord-#{channel_id}"
499
+ unless agent_dispatch_allowed?(depth_key)
500
+ sender_display = fizzy_display_name(sender_agent_key) || sender_agent_key.capitalize
501
+ agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
502
+ LOG.info "[Discord:#{agent_display}] Blocking cross-agent dispatch from #{sender_display} — depth limit reached"
503
+ return
504
+ end
505
+ record_agent_dispatch(depth_key)
506
+ end
507
+
508
+ # Detect if the message is a reply to one of this bot's own messages.
509
+ # Discord replies include a `message_reference` but don't automatically add
510
+ # the referenced author to the `mentions` array, so we check explicitly.
511
+ # We also cache the referenced message for later use as reply context.
512
+ is_reply_to_bot = false
513
+ referenced_message = nil
514
+ if message["message_reference"]
515
+ ref_msg_id = message.dig("message_reference", "message_id")
516
+ ref_channel = message.dig("message_reference", "channel_id") || channel_id
517
+ if ref_msg_id
518
+ referenced_message = discord_api(:get, "/channels/#{ref_channel}/messages/#{ref_msg_id}", token: bot_token)
519
+ is_reply_to_bot = !mentioned && referenced_message && referenced_message.dig("author", "id") == bot_user_id
520
+ end
521
+ end
522
+
523
+ # Detect if inside a thread (follow-up conversation) or a DM.
524
+ # Only call the API if the message doesn't already have an explicit @mention,
525
+ # to avoid unnecessary API calls on every message.
526
+ channel_info = nil
527
+ is_thread = false
528
+ is_dm = false
529
+ in_own_thread = false
530
+
531
+ if !mentioned && !is_reply_to_bot
532
+ channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token)
533
+ is_thread = channel_info && [11, 12].include?(channel_info["type"])
534
+ is_dm = channel_info && channel_info["type"] == 1
535
+ in_own_thread = is_thread && channel_info["owner_id"] == bot_user_id
536
+ end
537
+
538
+ # If we'd respond only because we own the thread (not explicitly mentioned,
539
+ # not a reply to us), check whether the human is explicitly talking to a
540
+ # DIFFERENT agent. If so, stand down — they're directing the conversation
541
+ # elsewhere and we shouldn't butt in.
542
+ if in_own_thread && !mentioned && !is_reply_to_bot && !is_bot
543
+ other_bot_mentioned = false
544
+ DISCORD_BOTS_MUTEX.synchronize do
545
+ DISCORD_BOTS.each do |key, info|
546
+ next if key == agent_key # skip self
547
+ next unless info[:user_id]
548
+
549
+ next unless mentions.any? { |m| m["id"].to_s == info[:user_id].to_s } ||
550
+ content.match?(/<@!?#{Regexp.escape(info[:user_id].to_s)}>/)
551
+
552
+ other_bot_mentioned = true
553
+ break
554
+ end
555
+ end
556
+
557
+ # Also check user_mappings for remote agent bots
558
+ unless other_bot_mentioned
559
+ user_mappings = DISCORD_CONFIG["user_mappings"] || {}
560
+ user_mappings.each_value do |discord_id|
561
+ next unless mentions.any? { |m| m["id"].to_s == discord_id.to_s } ||
562
+ content.match?(/<@!?#{Regexp.escape(discord_id.to_s)}>/)
563
+
564
+ other_bot_mentioned = true
565
+ break
566
+ end
567
+ end
568
+
569
+ if other_bot_mentioned
570
+ agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
571
+ LOG.info "[Discord:#{agent_display}] Standing down in own thread — human is directing message to another agent"
572
+ return
573
+ end
574
+ end
575
+
576
+ # In DMs, threads the bot created, and replies to the bot's own messages,
577
+ # respond without requiring an explicit @mention.
578
+ # In guild channels, require an explicit @mention.
579
+ return unless mentioned || in_own_thread || is_dm || is_reply_to_bot
580
+
581
+ # Human message resets the cross-agent dispatch depth for this channel/thread
582
+ record_human_comment("discord-#{channel_id}") unless is_bot
583
+
584
+ clean_content = content.gsub(/<@!?#{bot_user_id}>/, "").strip
585
+
586
+ # Handle attachments (images, gifs, etc.)
587
+ attachments = message["attachments"] || []
588
+ attachment_paths = []
589
+ agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
590
+ attachments.each do |att|
591
+ url = att["url"]
592
+ filename = att["filename"]
593
+ content_type = att["content_type"] || ""
594
+
595
+ # Only process image attachments
596
+ next unless content_type.start_with?("image/")
597
+
598
+ # Download to temp directory
599
+ temp_dir = File.join(ZILLACORE_DIR, "tmp", "discord", "attachments")
600
+ FileUtils.mkdir_p(temp_dir)
601
+ temp_path = File.join(temp_dir, "#{message_id}-#{filename}")
602
+
603
+ begin
604
+ uri = URI(url)
605
+ http = Net::HTTP.new(uri.host, uri.port)
606
+ http.use_ssl = true
607
+ response = http.get(uri.path + (uri.query ? "?#{uri.query}" : ""))
608
+
609
+ if response.code.to_i == 200
610
+ File.binwrite(temp_path, response.body)
611
+ attachment_paths << temp_path
612
+ LOG.info "[Discord:#{agent_display}] Downloaded attachment: #{filename} (#{content_type})"
613
+ else
614
+ LOG.warn "[Discord:#{agent_display}] Failed to download attachment #{filename}: HTTP #{response.code}"
615
+ end
616
+ rescue StandardError => e
617
+ LOG.error "[Discord:#{agent_display}] Error downloading attachment #{filename}: #{e.message}"
618
+ end
619
+ end
620
+
621
+ # Append attachment paths to the message content so kiro-cli can process them
622
+ unless attachment_paths.empty?
623
+ clean_content += "\n\n" unless clean_content.empty?
624
+ clean_content += attachment_paths.join("\n")
625
+ end
626
+
627
+ return if clean_content.empty? && attachment_paths.empty?
628
+
629
+ # Build reply context from the cached referenced message.
630
+ reply_context = ""
631
+ if referenced_message && referenced_message["content"]
632
+ ref_author = referenced_message.dig("author", "username") || "unknown"
633
+ ref_text = referenced_message["content"].strip
634
+ reply_context = "**Replying to #{ref_author}:**\n> #{ref_text}\n\n" unless ref_text.empty?
635
+ end
636
+
637
+ # Fetch recent channel history so the agent has conversational context.
638
+ # (Moved after is_thread detection below — needs thread status for limit)
639
+
640
+ discord_user = author["username"]
641
+ discord_user_id = author["id"]
642
+
643
+ # The agent name comes directly from the bot identity — no detection needed
644
+ agent_name = fizzy_display_name(agent_key) || agent_key.capitalize
645
+
646
+ # Fetch channel_info if we haven't already (mentioned path skipped it)
647
+ unless channel_info
648
+ channel_info = discord_api(:get, "/channels/#{channel_id}", token: bot_token)
649
+ is_thread = channel_info && [11, 12].include?(channel_info["type"])
650
+ is_dm = channel_info && channel_info["type"] == 1
651
+ end
652
+ parent_channel_id = is_thread ? channel_info&.dig("parent_id") || channel_id : channel_id
653
+
654
+ # Fetch recent channel history — threads get a larger window since they're bounded conversations.
655
+ history_limit = is_thread ? 25 : 10
656
+ channel_history = fetch_discord_channel_history(channel_id, message_id, token: bot_token, limit: history_limit)
657
+
658
+ LOG.info "[Discord:#{agent_name}] Message from #{discord_user} in #{if is_dm
659
+ "DM"
660
+ else
661
+ is_thread ? "thread" : "channel"
662
+ end} #{channel_id}: #{clean_content[0..100]}"
663
+
664
+ reload_projects!
665
+ reload_agent_registry!
666
+ reload_discord_config!
667
+
668
+ # Authorization
669
+ authorized_users = DISCORD_CONFIG["authorized_user_ids"] || []
670
+
671
+ # Support both role_mappings (hash) and authorized_role_ids (array or hash)
672
+ # If authorized_role_ids is a hash, treat it like role_mappings
673
+ authorized_roles = if DISCORD_CONFIG["role_mappings"]
674
+ DISCORD_CONFIG["role_mappings"].values
675
+ elsif DISCORD_CONFIG["authorized_role_ids"].is_a?(Hash)
676
+ DISCORD_CONFIG["authorized_role_ids"].values
677
+ else
678
+ DISCORD_CONFIG["authorized_role_ids"] || []
679
+ end
680
+
681
+ # Ensure all role IDs are strings (Discord API returns strings)
682
+ authorized_roles = authorized_roles.map(&:to_s)
683
+
684
+ unless authorized_users.empty? && authorized_roles.empty?
685
+ user_authorized = authorized_users.include?(discord_user_id)
686
+
687
+ # Fetch member roles — message.member is not always populated, so we need to
688
+ # fetch guild member info separately if we have a guild_id
689
+ member_roles = message.dig("member", "roles") || []
690
+
691
+ # If member roles aren't in the message and we have a guild_id, fetch them
692
+ if member_roles.empty? && message["guild_id"]
693
+ guild_member = fetch_guild_member(message["guild_id"], discord_user_id, token: bot_token)
694
+ member_roles = guild_member["roles"] || [] if guild_member
695
+ end
696
+
697
+ role_authorized = member_roles.intersect?(authorized_roles)
698
+
699
+ unless user_authorized || role_authorized
700
+ LOG.info "[Discord:#{agent_name}] Unauthorized user #{discord_user} (#{discord_user_id}), roles: #{member_roles.inspect}"
701
+ add_discord_reaction(channel_id, message_id, "🚫", token: bot_token)
702
+ return
703
+ end
704
+ end
705
+
706
+ # Inline tags: [project:my-project] and [model] anywhere in the message.
707
+ # Both are parsed for routing/config and stripped from the prompt content.
708
+ inline_project_key = nil
709
+ if (proj_match = clean_content.match(/\[project:(\S+)\]/i))
710
+ inline_project_key = proj_match[1]
711
+ clean_content = clean_content.sub(proj_match[0], "").strip
712
+ LOG.info "[Discord:#{agent_name}] Detected inline project tag: #{inline_project_key}"
713
+ end
714
+
715
+ # Strip model tag (e.g. [opus], [sonnet]) from prompt content — detect_model
716
+ # reads the original clean_content later, but we save the tag-free version
717
+ # for the actual prompt so the bracket noise doesn't leak through.
718
+ inline_model_tag = clean_content.match(/\[\w+\]/)
719
+ clean_content_for_prompt = inline_model_tag ? clean_content.sub(inline_model_tag[0], "").strip : clean_content
720
+
721
+ # Strip effort tag (e.g. [effort:high]) from prompt content
722
+ clean_content_for_prompt = clean_content_for_prompt.sub(/\[effort:\w+\]/i, "").strip
723
+
724
+ # Find project: inline override > channel mapping > default_project
725
+ if inline_project_key && PROJECTS.key?(inline_project_key)
726
+ project_key = inline_project_key
727
+ project_config = PROJECTS[inline_project_key]
728
+ LOG.info "[Discord:#{agent_name}] Using inline project: #{project_key} (#{project_config["repo_path"]})"
729
+ else
730
+ if inline_project_key && !PROJECTS.key?(inline_project_key)
731
+ LOG.warn "[Discord:#{agent_name}] Unknown inline project '#{inline_project_key}', falling back to channel mapping. Available: #{PROJECTS.keys.join(", ")}"
732
+ Thread.new { add_discord_reaction(channel_id, message_id, "⚠️", token: bot_token) }
733
+ end
734
+ project_key, project_config, _mapping = find_project_for_discord_channel(parent_channel_id)
735
+ if project_key
736
+ LOG.info "[Discord:#{agent_name}] Using channel-mapped project: #{project_key}"
737
+ else
738
+ LOG.info "[Discord:#{agent_name}] No project context (no inline tag or channel mapping)"
739
+ end
740
+ end
741
+
742
+ session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
743
+ supersede_key = "discord-#{agent_key}-#{channel_id}"
744
+
745
+ if session_active?(session_key)
746
+ add_discord_reaction(channel_id, message_id, "⏳", token: bot_token)
747
+ return
748
+ end
749
+
750
+ # Supersede: if a human sends a follow-up within 60s, kill the previous agent run
751
+ if !is_bot && (prev = find_supersedable_session(supersede_key))
752
+ LOG.info "[Discord:#{agent_name}] Superseding previous session #{prev[:session_key]} (pid: #{prev[:pid]}) for follow-up from #{discord_user}"
753
+ kill_session(prev[:session_key])
754
+ # React on the OLD message to show it was cancelled
755
+ if prev[:message_id] && prev[:channel_id]
756
+ Thread.new do
757
+ remove_discord_reaction(prev[:channel_id], prev[:message_id], "👀", token: bot_token)
758
+ add_discord_reaction(prev[:channel_id], prev[:message_id], "❌", token: bot_token)
759
+ end
760
+ end
761
+ # Clean up draft files from the superseded session so the poller doesn't deliver stale responses
762
+ (prev[:draft_files] || []).each { |f| FileUtils.rm_f(f) }
763
+ end
764
+
765
+ # React in background — don't block the dispatch path
766
+ # Remove 🛑 if it exists (user may have cancelled and is now retrying via edit)
767
+ Thread.new do
768
+ remove_discord_reaction(channel_id, message_id, "🛑", token: bot_token)
769
+ add_discord_reaction(channel_id, message_id, "👀", token: bot_token)
770
+ end
771
+
772
+ # Build project context
773
+ if project_config
774
+ repo_path = project_config["repo_path"]
775
+ # Fetch latest from origin so worktrees branch from up-to-date main
776
+ debounced_repo_fetch(repo_path)
777
+ default_branch = get_default_branch(repo_path)
778
+ lines = ["## Project Context"]
779
+ lines << "Project: #{project_key}"
780
+ lines << "Source directory: `#{repo_path}`"
781
+ lines << "Default branch: `#{default_branch}`"
782
+ lines << "GitHub: #{project_config["github_repo"]}" if project_config["github_repo"]
783
+ lines << ""
784
+ lines << "This is the project's source code directory. When asked to modify, inspect, or work on this project, go directly to `#{repo_path}` — do NOT search for it."
785
+ lines << ""
786
+ lines << "### All registered projects"
787
+ PROJECTS.each do |key, cfg|
788
+ lines << "- **#{key}**: `#{cfg["repo_path"]}`"
789
+ end
790
+ context = lines.join("\n")
791
+ LOG.info "[Discord:#{agent_name}] Built project context for #{project_key} (#{repo_path})"
792
+ else
793
+ lines = ["## Project Context"]
794
+ lines << "No specific project mapped to this channel."
795
+ lines << ""
796
+ lines << "### Registered projects (use `[project:name]` to target one)"
797
+ PROJECTS.each do |key, cfg|
798
+ lines << "- **#{key}**: `#{cfg["repo_path"]}`"
799
+ end
800
+ context = lines.join("\n")
801
+ LOG.info "[Discord:#{agent_name}] No project context - showing available projects"
802
+ end
803
+ project_context = context
804
+
805
+ # Prepare files — response goes to draft/ so the poller can recover it after restarts
806
+ response_dir = File.join(ZILLACORE_DIR, "tmp")
807
+ FileUtils.mkdir_p(response_dir)
808
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
809
+ response_basename = "discord-response-#{timestamp}-#{agent_key}-#{message_id}"
810
+ response_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.md")
811
+
812
+ channel_name = channel_info&.dig("name") || channel_id
813
+
814
+ # Find the root message for this conversation thread.
815
+ # All messages in a thread should share the same memory file.
816
+ # Also captures root message content so the agent always has the original context.
817
+ root_message = find_root_message(message, channel_id, bot_token)
818
+ root_message_id = root_message[:id]
819
+ card_id = "discord-#{channel_id}-#{root_message_id}"
820
+
821
+ # Build thread root context — inject the original question/message that started
822
+ # this thread so the agent never loses sight of it, even in long conversations.
823
+ thread_root_context = ""
824
+ if is_thread && root_message[:content] && !root_message[:content].empty?
825
+ root_author = root_message[:author] || "unknown"
826
+ thread_root_context = "### Original Message (thread starter)\n#{root_author}: #{root_message[:content]}\n\n"
827
+ end
828
+
829
+ # Detect planning mode
830
+ planning_info = detect_planning_mode(
831
+ text: clean_content,
832
+ tags: [],
833
+ card_internal_id: card_id,
834
+ card_number: nil
835
+ )
836
+
837
+ brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
838
+
839
+ if planning_info
840
+ # Planning mode — use planning prompt
841
+ planning_card_id = planning_info[:card_id]
842
+ LOG.info "[Discord:#{agent_name}] Planning mode detected for #{discord_user}"
843
+
844
+ prompt = render_planning_prompt(PROMPT_DISCORD,
845
+ { "DISCORD_USER" => discord_user,
846
+ "CHANNEL_NAME" => channel_name,
847
+ "MESSAGE_BODY" => clean_content_for_prompt.sub(/\[plan\]/i, "").strip,
848
+ "REPLY_CONTEXT" => reply_context,
849
+ "CHANNEL_HISTORY" => channel_history,
850
+ "THREAD_ROOT_CONTEXT" => thread_root_context,
851
+ "PROJECT_CONTEXT" => project_context,
852
+ "RESPONSE_FILE" => response_file,
853
+ "CARD_ID" => planning_card_id,
854
+ "COMMENT_CREATOR" => discord_user,
855
+ "DISCORD_MENTION_ROSTER" => discord_mention_roster },
856
+ brain_context: brain_context,
857
+ agent_name: agent_name,
858
+ channel: :discord)
859
+ else
860
+ # Normal mode
861
+ prompt = render_prompt(PROMPT_DISCORD,
862
+ { "DISCORD_USER" => discord_user,
863
+ "CHANNEL_NAME" => channel_name,
864
+ "MESSAGE_BODY" => clean_content_for_prompt,
865
+ "REPLY_CONTEXT" => reply_context,
866
+ "CHANNEL_HISTORY" => channel_history,
867
+ "THREAD_ROOT_CONTEXT" => thread_root_context,
868
+ "PROJECT_CONTEXT" => project_context,
869
+ "RESPONSE_FILE" => response_file,
870
+ "CARD_ID" => card_id,
871
+ "COMMENT_CREATOR" => discord_user,
872
+ "DISCORD_MENTION_ROSTER" => discord_mention_roster },
873
+ brain_context: brain_context,
874
+ agent_name: agent_name,
875
+ channel: :discord)
876
+ end
877
+
878
+ work_dir = project_config ? project_config["repo_path"] : Dir.pwd
879
+
880
+ prompt_file = File.join(response_dir, "discord-prompt-#{timestamp}-#{agent_key}-#{message_id}.md")
881
+ File.write(prompt_file, prompt)
882
+
883
+ # Write delivery metadata sidecar so the poller can post this response
884
+ # even if the monitoring thread dies (e.g. server restart).
885
+ meta_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json")
886
+ File.write(meta_file, JSON.pretty_generate({
887
+ channel_id: channel_id,
888
+ message_id: message_id,
889
+ agent_key: agent_key,
890
+ agent_name: agent_name,
891
+ is_dm: is_dm,
892
+ is_thread: is_thread,
893
+ clean_content: clean_content[0..80],
894
+ created_at: Time.now.iso8601
895
+ }))
896
+
897
+ # Detect model override — same [opus]/[sonnet]/[haiku] syntax as Fizzy comments
898
+ model = project_config ? detect_model(project_config, text: clean_content) : nil
899
+
900
+ # Detect effort override — [effort:high] syntax
901
+ effort = project_config ? detect_effort(project_config, text: clean_content) : nil
902
+
903
+ agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
904
+ log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
905
+
906
+ resolved = project_config ? resolve_project_cli_config(project_config) : {}
907
+ agent_cli = resolved["agent_cli"] || "kiro-cli"
908
+ agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
909
+ agent_model_flag = resolved["agent_model_flag"] || "--model"
910
+ agent_effort_flag = resolved["agent_effort_flag"] || "--effort"
911
+
912
+ cmd = [agent_cli]
913
+ cmd.push("--agent", agent_config_name)
914
+ cmd.concat(agent_cli_args.split)
915
+ add_trust_tools!(cmd, agent_cli_args)
916
+ cmd.push(agent_model_flag, model) if agent_model_flag && !agent_model_flag.empty? && model
917
+ cmd.push(agent_effort_flag, effort) if agent_effort_flag && !agent_effort_flag.empty? && effort
918
+
919
+ LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}), tail -f #{log_file}"
920
+ LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}"
921
+
922
+ spawn_env = {}
923
+ agent_env = agent_env_for(agent_name)
924
+ unless agent_env.empty?
925
+ spawn_env.merge!(agent_env)
926
+ LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}"
927
+ end
928
+
929
+ # Capture HEAD before spawning so we can detect if THIS session made commits
930
+ head_before = nil
931
+ if project_config
932
+ pk = PROJECTS.find { |_k, v| v == project_config }&.first
933
+ if pk == "zillacore"
934
+ head_before, = Open3.capture2("git", "rev-parse", "HEAD", chdir: work_dir)
935
+ head_before = head_before.strip
936
+ end
937
+ end
938
+
939
+ pid = spawn(spawn_env, *cmd,
940
+ chdir: work_dir,
941
+ in: prompt_file,
942
+ out: [log_file, "w"],
943
+ err: %i[child out])
944
+
945
+ register_session(session_key, pid, log_file: log_file,
946
+ message_id: message_id, channel_id: channel_id,
947
+ supersede_key: supersede_key,
948
+ draft_files: [response_file, meta_file],
949
+ agent_name: agent_name)
950
+
951
+ Thread.new do
952
+ Process.wait(pid)
953
+ exit_status = $CHILD_STATUS
954
+
955
+ # Check if session was cancelled (removed from ACTIVE_SESSIONS by reaction handler)
956
+ session_cancelled = ACTIVE_SESSIONS_MUTEX.synchronize { !ACTIVE_SESSIONS.key?(session_key) }
957
+
958
+ # If the process was killed by a signal (superseded or cancelled), skip response delivery
959
+ if exit_status.signaled? || session_cancelled
960
+ reason = session_cancelled ? "cancelled" : "superseded (signal: #{exit_status.termsig})"
961
+ LOG.info "[Discord:#{agent_name}] Agent was #{reason} for message #{message_id}"
962
+ # Clean up draft/meta files so the poller doesn't deliver a stale response
963
+ [response_file, meta_file].each { |f| FileUtils.rm_f(f) }
964
+ Thread.new do
965
+ sleep 300
966
+ [prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
967
+ end
968
+ next
969
+ end
970
+
971
+ LOG.info "[Discord:#{agent_name}] Agent finished for message #{message_id} (exit: #{exit_status.exitstatus})"
972
+
973
+ # Notify if the agent crashed (non-zero exit)
974
+ if exit_status.exitstatus && exit_status.exitstatus != 0
975
+ notify_agent_crash(
976
+ exit_status: exit_status.exitstatus, log_file: log_file,
977
+ agent_name: agent_name, source: :discord,
978
+ source_context: { channel_id: channel_id, message_id: message_id, bot_token: bot_token },
979
+ project_config: project_config
980
+ )
981
+ end
982
+
983
+ # If the agent didn't write to the response file, extract it from the log.
984
+ # Agents should write to the file directly, but this is a fallback for when
985
+ # they respond via stdout instead.
986
+ if !File.exist?(response_file) && File.exist?(log_file)
987
+ log_content = File.read(log_file)
988
+
989
+ # Detect known fatal error patterns from kiro-cli and write a clean
990
+ # user-facing message instead of leaking raw internal errors to Discord.
991
+ if exit_status.exitstatus != 0 && log_content.match?(/InternalServerError|Encountered an unexpected error|Failed to receive the next message/i)
992
+ LOG.warn "[Discord:#{agent_name}] Agent hit an upstream error for message #{message_id}"
993
+ File.write(response_file, "_Sorry, I hit a temporary error on the backend. Please try again._")
994
+ elsif log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/)
995
+ LOG.error "[Discord:#{agent_name}] Auth failure detected — re-authenticate with: kiro-cli --agent #{agent_config_name} chat"
996
+ FileUtils.rm_f(meta_file)
997
+ else
998
+ # Strip ANSI codes and kiro-cli UI noise
999
+ clean_output = log_content
1000
+ .gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "") # ANSI escape codes (including cursor visibility)
1001
+ .gsub(/\e\][^\a]*\a/, "") # OSC sequences
1002
+ .delete("\r") # Carriage returns
1003
+ .gsub(/^.*?(using tool:.*?)$/m, "") # Tool usage lines
1004
+ .gsub(/^.*?✓.*?$/m, "") # Success checkmarks
1005
+ .gsub(/^.*?▸.*?$/m, "") # Timing lines
1006
+ .gsub(/^.*?Loading\.\.\..*?$/m, "") # Loading indicators
1007
+ .gsub(/^.*?Completed in.*?$/m, "") # Completion messages
1008
+ .strip
1009
+
1010
+ # Only write if there's actual content
1011
+ if !clean_output.empty? && clean_output.length > 20
1012
+ File.write(response_file, clean_output)
1013
+ LOG.info "[Discord:#{agent_name}] Extracted response from log (#{clean_output.length} chars)"
1014
+ end
1015
+ end
1016
+ end
1017
+
1018
+ # Deliver Discord response FIRST for faster human feedback
1019
+ remove_discord_reaction(channel_id, message_id, "👀", token: bot_token)
1020
+ sleep 0.5 # Breathing room to avoid Discord rate limits
1021
+
1022
+ delivered = deliver_discord_draft(response_file, meta_file)
1023
+
1024
+ # If deliver returned false, check whether the poller already handled it
1025
+ # (files moved to posted/) or the response genuinely doesn't exist.
1026
+ unless delivered
1027
+ response_basename = File.basename(response_file)
1028
+ already_posted = File.exist?(File.join(DISCORD_POSTED_DIR, response_basename))
1029
+ unless already_posted
1030
+ LOG.warn "[Discord:#{agent_name}] No response produced for message #{message_id}"
1031
+ add_discord_reaction(channel_id, message_id, "😶", token: bot_token)
1032
+ end
1033
+ end
1034
+
1035
+ # Re-index brain AFTER response delivery (Discord bypasses run_agent, so we handle it here)
1036
+ qmd_out, qmd_status = Open3.capture2e("qmd", "update")
1037
+ if qmd_status.success?
1038
+ LOG.info "[Brain] qmd update completed after #{agent_name} Discord session"
1039
+ else
1040
+ LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
1041
+ end
1042
+
1043
+ brain_push(message: "#{agent_name}: discord-#{message_id}")
1044
+
1045
+ # Restart zillacore if THIS session actually changed code
1046
+ # Compare HEAD now vs before the agent ran — only restart if commits were made or files are dirty
1047
+ if project_config && head_before
1048
+ project_key = PROJECTS.find { |_k, v| v == project_config }&.first
1049
+ if project_key == "zillacore"
1050
+ chdir = project_config["repo_path"]
1051
+ head_after, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
1052
+ git_status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
1053
+ if head_after.strip != head_before || !git_status.strip.empty?
1054
+ queue_zillacore_restart(agent_name)
1055
+ else
1056
+ LOG.info "[ZillaCore] #{agent_name} Discord session on zillacore had no changes — skipping restart"
1057
+ end
1058
+ end
1059
+ end
1060
+
1061
+ Thread.new do
1062
+ sleep 300
1063
+ [prompt_file, *attachment_paths].each { |f| FileUtils.rm_f(f) }
1064
+ end
1065
+ rescue StandardError => e
1066
+ LOG.error "[Discord:#{agent_name}] Error monitoring agent: #{e.message}"
1067
+ add_discord_reaction(channel_id, message_id, "❌", token: bot_token)
1068
+ end
1069
+ end
1070
+
1071
+ # --- Discord Reaction Handler ---
1072
+ # Handles MESSAGE_REACTION_ADD events. Currently supports:
1073
+ # - ❌ reaction to cancel an active agent session
1074
+ def handle_discord_reaction(reaction_data, agent_key, bot_token, bot_user_id)
1075
+ channel_id = reaction_data["channel_id"]
1076
+ message_id = reaction_data["message_id"]
1077
+ user_id = reaction_data["user_id"]
1078
+ emoji = reaction_data["emoji"]
1079
+ emoji_name = emoji["name"]
1080
+
1081
+ agent_name = fizzy_display_name(agent_key) || agent_key.capitalize
1082
+
1083
+ # Ignore reactions from bots (including self)
1084
+ return if user_id == bot_user_id
1085
+
1086
+ # Handle ❔ or ❓ reactions (thinking file inspection)
1087
+ if ["❔", "❓"].include?(emoji_name)
1088
+ session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
1089
+ line_count = emoji_name == "❔" ? 10 : 20
1090
+
1091
+ ACTIVE_SESSIONS_MUTEX.synchronize do
1092
+ session_info = ACTIVE_SESSIONS[session_key]
1093
+
1094
+ unless session_info
1095
+ LOG.info "[Discord:#{agent_name}] #{emoji_name} reaction on #{message_id} but no active session found"
1096
+ return
1097
+ end
1098
+
1099
+ log_file = session_info[:log_file]
1100
+ unless log_file && File.exist?(log_file)
1101
+ LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}"
1102
+ send_discord_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
1103
+ return
1104
+ end
1105
+
1106
+ LOG.info "[Discord:#{agent_name}] Reading last #{line_count} lines from #{log_file}"
1107
+
1108
+ # Read last N lines from the log file
1109
+ lines = File.readlines(log_file).last(line_count)
1110
+ thinking_output = lines.join
1111
+
1112
+ # Strip all ANSI escape codes and non-ASCII characters
1113
+ thinking_output = thinking_output.gsub(/\e\[[0-9;]*[a-zA-Z]/, "") # All CSI sequences
1114
+ .gsub(/\x1b\[[0-9;]*[a-zA-Z]/, "") # Alternative CSI notation
1115
+ .gsub(/\e\][0-9;]*.*?(\x07|\e\\)/, "") # OSC sequences
1116
+ .gsub(/\e[=>]/, "") # Other escape sequences
1117
+ .gsub(/\[\?[0-9]+[lh]/, "") # Cursor visibility
1118
+ .gsub("[K", "") # Clear line
1119
+ .encode("ASCII", invalid: :replace, undef: :replace, replace: "") # Strip non-ASCII
1120
+ .strip
1121
+
1122
+ # Post to Discord as a code block
1123
+ response = "**Last #{line_count} lines:**\n```\n#{thinking_output}\n```"
1124
+ send_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
1125
+ end
1126
+ return
1127
+ end
1128
+
1129
+ # Handle 🧠 reaction (stream full thinking to thread)
1130
+ if emoji_name == "🧠"
1131
+ session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
1132
+
1133
+ ACTIVE_SESSIONS_MUTEX.synchronize do
1134
+ session_info = ACTIVE_SESSIONS[session_key]
1135
+
1136
+ unless session_info
1137
+ LOG.info "[Discord:#{agent_name}] 🧠 reaction on #{message_id} but no active session found"
1138
+ return
1139
+ end
1140
+
1141
+ log_file = session_info[:log_file]
1142
+ unless log_file && File.exist?(log_file)
1143
+ LOG.warn "[Discord:#{agent_name}] No log file found for session #{session_key}"
1144
+ send_discord_message(channel_id, "No thinking file found for this session.", token: bot_token, reply_to: message_id)
1145
+ return
1146
+ end
1147
+
1148
+ LOG.info "[Discord:#{agent_name}] Creating thread and streaming thinking from #{log_file}"
1149
+
1150
+ # Create thread
1151
+ thread_response = create_discord_thread(channel_id, message_id, name: "🧠 Thinking Stream", token: bot_token)
1152
+ unless thread_response && thread_response["id"]
1153
+ LOG.error "[Discord:#{agent_name}] Failed to create thread, response: #{thread_response.inspect}"
1154
+ return
1155
+ end
1156
+
1157
+ thread_id = thread_response["id"]
1158
+ LOG.info "[Discord:#{agent_name}] Thread created: #{thread_id}"
1159
+
1160
+ # Read and clean full thinking file
1161
+ thinking_content = File.read(log_file)
1162
+ thinking_content = thinking_content.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
1163
+ .gsub(/\x1b\[[0-9;]*[a-zA-Z]/, "")
1164
+ .gsub(/\e\][0-9;]*.*?(\x07|\e\\)/, "")
1165
+ .gsub(/\e[=>]/, "")
1166
+ .gsub(/\[\?[0-9]+[lh]/, "")
1167
+ .gsub("[K", "")
1168
+ .encode("ASCII", invalid: :replace, undef: :replace, replace: "")
1169
+ .strip
1170
+
1171
+ # Split into 1900-char chunks (leave room for code blocks)
1172
+ chunks = []
1173
+ current_chunk = ""
1174
+ thinking_content.lines.each do |line|
1175
+ if current_chunk.length + line.length > 1900
1176
+ chunks << current_chunk
1177
+ current_chunk = line
1178
+ else
1179
+ current_chunk += line
1180
+ end
1181
+ end
1182
+ chunks << current_chunk unless current_chunk.empty?
1183
+
1184
+ # Post chunks to thread
1185
+ chunks.each do |chunk|
1186
+ send_discord_message(thread_id, "```\n#{chunk}\n```", token: bot_token)
1187
+ sleep 0.5 # Rate limit protection
1188
+ end
1189
+ end
1190
+ return
1191
+ end
1192
+
1193
+ # --- Feedback logging for non-reserved emojis ---
1194
+ unless RESERVED_EMOJIS.include?(emoji_name)
1195
+ Thread.new do
1196
+ log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
1197
+ rescue StandardError => e
1198
+ LOG.warn "[Discord:#{agent_name}] Feedback logging failed: #{e.message}"
1199
+ end
1200
+ return
1201
+ end
1202
+
1203
+ # Only handle ❌ reactions beyond this point
1204
+ return unless emoji_name == "❌"
1205
+
1206
+ # Check if there's an active session for this message
1207
+ session_key = "discord-#{agent_key}-#{channel_id}-#{message_id}"
1208
+
1209
+ ACTIVE_SESSIONS_MUTEX.synchronize do
1210
+ session_info = ACTIVE_SESSIONS[session_key]
1211
+
1212
+ unless session_info
1213
+ LOG.info "[Discord:#{agent_name}] ❌ reaction on #{message_id} but no active session found"
1214
+ return
1215
+ end
1216
+
1217
+ LOG.info "[Discord:#{agent_name}] Cancelling session for message #{message_id} (PID: #{session_info[:pid]})"
1218
+
1219
+ # Kill the agent process
1220
+ begin
1221
+ Process.kill("KILL", session_info[:pid])
1222
+ LOG.info "[Discord:#{agent_name}] Killed agent process #{session_info[:pid]}"
1223
+ rescue Errno::ESRCH
1224
+ LOG.warn "[Discord:#{agent_name}] Process #{session_info[:pid]} already exited"
1225
+ rescue Errno::EPERM
1226
+ LOG.error "[Discord:#{agent_name}] Permission denied killing process #{session_info[:pid]}"
1227
+ end
1228
+
1229
+ # Remove from active sessions
1230
+ ACTIVE_SESSIONS.delete(session_key)
1231
+
1232
+ # Update reactions: remove 👀, add 🛑
1233
+ begin
1234
+ remove_discord_reaction(channel_id, message_id, "👀", token: bot_token)
1235
+ add_discord_reaction(channel_id, message_id, "🛑", token: bot_token)
1236
+ rescue StandardError => e
1237
+ LOG.warn "[Discord:#{agent_name}] Failed to update reactions: #{e.message}"
1238
+ end
1239
+
1240
+ # Clean up draft files if they exist
1241
+ session_info[:draft_files]&.each do |file|
1242
+ FileUtils.rm_f(file)
1243
+ end
1244
+ end
1245
+ end
1246
+
1247
+ # --- Emoji Feedback Logging ---
1248
+ # Logs non-reserved emoji reactions on bot messages to the agent's persona feedback file.
1249
+ # No LLM call, no dispatch — just a file append.
1250
+
1251
+ def log_emoji_feedback(channel_id, message_id, user_id, emoji_name, agent_key, agent_name, bot_token)
1252
+ # Verify the message was posted by this bot (quiet — bots get reactions from channels they can't access)
1253
+ msg = fetch_discord_message(channel_id, message_id, token: bot_token, log_errors: false)
1254
+ return unless msg&.dig("author", "bot")
1255
+
1256
+ bot_user_id = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :user_id) }
1257
+ return unless bot_user_id && msg.dig("author", "id") == bot_user_id
1258
+
1259
+ # Resolve reactor to canonical name
1260
+ reactor = find_user_by_discord_id(user_id)
1261
+ reactor_name = reactor ? reactor["canonical_name"] : user_id
1262
+
1263
+ # Build a brief context snippet from the message
1264
+ snippet = (msg["content"] || "")[0, 80].tr("\n", " ").strip
1265
+ snippet = "#{snippet}..." if (msg["content"] || "").length > 80
1266
+
1267
+ # Append to persona feedback file
1268
+ feedback_dir = File.join(persona_dir_for(agent_name), "people")
1269
+ FileUtils.mkdir_p(feedback_dir)
1270
+ feedback_file = File.join(feedback_dir, "#{reactor_name.downcase.gsub(/[^a-z0-9]/, "-")}-feedback.md")
1271
+
1272
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M")
1273
+ entry = "- #{timestamp} #{emoji_name} on: \"#{snippet}\" (channel: #{channel_id})\n"
1274
+
1275
+ # Create file with header if new
1276
+ if File.exist?(feedback_file)
1277
+ File.open(feedback_file, "a") { |f| f.write(entry) }
1278
+ else
1279
+ File.write(feedback_file, "# Feedback from #{reactor_name}\n\n## Reaction Log\n#{entry}")
1280
+ end
1281
+
1282
+ LOG.info "[Discord:#{agent_name}] Logged #{emoji_name} feedback from #{reactor_name} on message #{message_id}"
1283
+ end
1284
+
1285
+ # --- Discord Draft Delivery ---
1286
+ # Shared logic for posting a draft response file to Discord and moving it to posted/.
1287
+ # Used by both the monitoring thread (happy path) and the poller (recovery path).
1288
+
1289
+ def deliver_discord_draft(response_file, meta_file)
1290
+ return false unless File.exist?(meta_file)
1291
+
1292
+ # Simple file-based lock to prevent the monitoring thread and poller
1293
+ # from delivering the same draft simultaneously.
1294
+ lock_file = "#{meta_file}.lock"
1295
+ begin
1296
+ File.open(lock_file, File::CREAT | File::EXCL | File::WRONLY) {} # atomic create-or-fail
1297
+ rescue Errno::EEXIST
1298
+ return false # Another thread is already delivering this draft
1299
+ end
1300
+
1301
+ meta = JSON.parse(File.read(meta_file))
1302
+ channel_id = meta["channel_id"]
1303
+ message_id = meta["message_id"]
1304
+ agent_key = meta["agent_key"]
1305
+ agent_name = meta["agent_name"]
1306
+ is_dm = meta["is_dm"]
1307
+ is_thread = meta["is_thread"]
1308
+ clean_content = meta["clean_content"] || ""
1309
+
1310
+ # Look up the bot token from the current registry
1311
+ bot_token = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.dig(agent_key, :token) }
1312
+ bot_token ||= (AGENT_REGISTRY.dig(agent_key, "env") || {})["DISCORD_BOT_TOKEN"]
1313
+
1314
+ unless bot_token
1315
+ LOG.warn "[Discord:#{agent_name}] No bot token found for #{agent_key}, cannot deliver draft"
1316
+ FileUtils.rm_f(lock_file)
1317
+ return false
1318
+ end
1319
+
1320
+ if File.exist?(response_file)
1321
+ response = File.read(response_file).strip
1322
+ if response.empty?
1323
+ add_discord_reaction(channel_id, message_id, "😶", token: bot_token) if message_id
1324
+ send_discord_message(channel_id, "_#{agent_name} had nothing to say._", token: bot_token)
1325
+ elsif is_dm || is_thread || message_id.nil?
1326
+ # DMs, threads, and cron jobs (no message_id) need special handling
1327
+ # Check if this is a forum channel
1328
+ if message_id.nil? && forum_channel?(channel_id, token: bot_token)
1329
+ title = meta["forum_title"] || "#{agent_name} — #{Time.now.strftime("%b %d, %Y")}"
1330
+ if meta["forum_reply_to_latest"]
1331
+ latest_thread = find_latest_forum_thread(channel_id, token: bot_token)
1332
+ if latest_thread
1333
+ send_long_discord_message(latest_thread["id"], response, token: bot_token)
1334
+ else
1335
+ LOG.warn "[Discord:#{agent_name}] No existing thread found, creating new forum post"
1336
+ create_forum_post(channel_id, title: title, content: response, token: bot_token)
1337
+ end
1338
+ else
1339
+ create_forum_post(channel_id, title: title, content: response, token: bot_token)
1340
+ end
1341
+ else
1342
+ # Regular DM, thread, or text channel
1343
+ send_long_discord_message(channel_id, response, token: bot_token)
1344
+ end
1345
+ else
1346
+ # Check if another agent (local OR remote) already created a thread
1347
+ # for this message. Three-tier lookup:
1348
+ # 1. Local in-memory cache (DISCORD_SHARED_THREADS) — fast, same-machine
1349
+ # 2. Discord API — fetch the original message and check its `thread` field
1350
+ # This is the cross-machine fix: if machine B's agent finishes after
1351
+ # machine A already created a thread, the API will reveal it.
1352
+ # 3. Create a new thread if neither found one.
1353
+ # The mutex still covers the local check + create to prevent same-machine races.
1354
+ thread_id = nil
1355
+ created_thread = false
1356
+ DISCORD_SHARED_THREADS_MUTEX.synchronize do
1357
+ thread_id = DISCORD_SHARED_THREADS[message_id]
1358
+
1359
+ # Tier 2: Ask Discord if a thread already exists on this message.
1360
+ # This catches threads created by agents on other machines.
1361
+ unless thread_id
1362
+ original_msg = discord_api(:get, "/channels/#{channel_id}/messages/#{message_id}", token: bot_token)
1363
+ if original_msg&.dig("thread", "id")
1364
+ thread_id = original_msg["thread"]["id"]
1365
+ DISCORD_SHARED_THREADS[message_id] = thread_id
1366
+ LOG.info "[Discord:#{agent_name}] Discovered existing thread #{thread_id} on message #{message_id} via API"
1367
+ end
1368
+ end
1369
+
1370
+ # Tier 3: No thread exists anywhere — create one.
1371
+ unless thread_id
1372
+ display_name = fizzy_display_name(agent_key)
1373
+ thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
1374
+ if thread && thread["id"]
1375
+ thread_id = thread["id"]
1376
+ DISCORD_SHARED_THREADS[message_id] = thread_id
1377
+ created_thread = true
1378
+ LOG.info "[Discord:#{agent_name}] Created shared thread #{thread_id} for message #{message_id}"
1379
+ end
1380
+ end
1381
+ end
1382
+
1383
+ if thread_id
1384
+ LOG.info "[Discord:#{agent_name}] Joining shared thread #{thread_id} for message #{message_id}" unless created_thread
1385
+
1386
+ # Propagate the parent channel's dispatch depth to the thread so
1387
+ # cross-agent mentions inside the thread aren't blocked immediately.
1388
+ # The human's message was in the parent channel, but agent responses
1389
+ # land in this thread (different channel_id). Without this, the
1390
+ # thread's depth key has no entry and agent_dispatch_allowed? returns false.
1391
+ parent_depth_key = "discord-#{channel_id}"
1392
+ thread_depth_key = "discord-#{thread_id}"
1393
+ parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
1394
+ unless AGENT_DISPATCH_DEPTH[thread_depth_key]
1395
+ if parent_info
1396
+ AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
1397
+ LOG.info "[Discord:#{agent_name}] Propagated dispatch depth from channel #{channel_id} to thread #{thread_id}"
1398
+ else
1399
+ # No parent depth entry (edge case) — initialize with current time
1400
+ record_human_comment(thread_depth_key)
1401
+ end
1402
+ end
1403
+
1404
+ send_discord_typing(thread_id, token: bot_token)
1405
+ send_long_discord_message(thread_id, response, token: bot_token)
1406
+ else
1407
+ LOG.warn "[Discord:#{agent_name}] Thread creation failed, falling back to reply"
1408
+ send_long_discord_message(channel_id, response, token: bot_token, reply_to: message_id)
1409
+ end
1410
+ end
1411
+ else
1412
+ # Response file doesn't exist yet — agent may still be running
1413
+ FileUtils.rm_f(lock_file)
1414
+ return false
1415
+ end
1416
+
1417
+ # Move both files to posted/
1418
+ basename = File.basename(response_file)
1419
+ meta_basename = File.basename(meta_file)
1420
+ FileUtils.mv(response_file, File.join(DISCORD_POSTED_DIR, basename)) if File.exist?(response_file)
1421
+ FileUtils.mv(meta_file, File.join(DISCORD_POSTED_DIR, meta_basename))
1422
+ FileUtils.rm_f(lock_file)
1423
+ LOG.info "[Discord:#{agent_name}] Draft delivered and moved to posted/"
1424
+ true
1425
+ rescue StandardError => e
1426
+ LOG.error "[Discord] Failed to deliver draft #{meta_file}: #{e.message}"
1427
+ File.delete(lock_file) if lock_file && File.exist?(lock_file)
1428
+ false
1429
+ end
1430
+
1431
+ # Poller thread: scans draft/ for orphaned response files and delivers them.
1432
+ # Runs every 5 seconds. Only attempts delivery if the response file exists
1433
+ # (meaning the agent finished) and the meta file is at least 30 seconds old
1434
+ # (giving the monitoring thread a chance to handle it first).
1435
+
1436
+ DISCORD_DRAFT_POLLER_INTERVAL = 5 # seconds
1437
+ DISCORD_DRAFT_MIN_AGE = 30 # seconds — don't race the monitoring thread
1438
+
1439
+ def start_discord_draft_poller
1440
+ Thread.new do
1441
+ LOG.info "[Discord] Draft poller started, checking #{DISCORD_DRAFT_DIR} every #{DISCORD_DRAFT_POLLER_INTERVAL}s"
1442
+ loop do
1443
+ sleep DISCORD_DRAFT_POLLER_INTERVAL
1444
+ begin
1445
+ # Clean up stale lock files (older than 60s) left by crashed deliveries
1446
+ Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.lock")).each do |lock_file|
1447
+ File.delete(lock_file) if (Time.now - File.mtime(lock_file)) > 60
1448
+ end
1449
+
1450
+ Dir.glob(File.join(DISCORD_DRAFT_DIR, "*.meta.json")).each do |meta_file|
1451
+ # Don't race the monitoring thread — wait for the file to age
1452
+ next if (Time.now - File.mtime(meta_file)) < DISCORD_DRAFT_MIN_AGE
1453
+
1454
+ # Cron metas: foo.md.meta.json → foo.md
1455
+ # Discord metas: foo.meta.json → foo.md
1456
+ response_file = if meta_file.end_with?(".md.meta.json")
1457
+ meta_file.sub(".md.meta.json", ".md")
1458
+ else
1459
+ meta_file.sub(".meta.json", ".md")
1460
+ end
1461
+ next unless File.exist?(response_file)
1462
+
1463
+ LOG.info "[Discord] Poller recovering orphaned draft: #{File.basename(meta_file)}"
1464
+ deliver_discord_draft(response_file, meta_file)
1465
+ end
1466
+ rescue StandardError => e
1467
+ LOG.error "[Discord] Draft poller error: #{e.message}"
1468
+ end
1469
+ end
1470
+ end
1471
+ end
1472
+
1473
+ # --- Discord Gateway (one per agent bot) ---
1474
+
1475
+ def start_discord_gateway_for(agent_key, bot_token)
1476
+ Thread.new do
1477
+ agent_display = fizzy_display_name(agent_key) || agent_key.capitalize
1478
+ bot_user_id = nil
1479
+
1480
+ loop do
1481
+ DISCORD_BOTS_MUTEX.synchronize do
1482
+ DISCORD_BOTS[agent_key] ||= {}
1483
+ DISCORD_BOTS[agent_key][:status] = "connecting"
1484
+ DISCORD_BOTS[agent_key][:token] = bot_token
1485
+ end
1486
+
1487
+ LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..."
1488
+
1489
+ heartbeat_thread = nil
1490
+ last_sequence = nil
1491
+
1492
+ ws = WebSocket::Client::Simple.connect(DISCORD_GATEWAY_URL)
1493
+
1494
+ ws.on :message do |msg|
1495
+ next if msg.data.nil? || msg.data.empty?
1496
+
1497
+ payload = JSON.parse(msg.data)
1498
+ op = payload["op"]
1499
+ data = payload["d"]
1500
+ last_sequence = payload["s"] if payload["s"]
1501
+
1502
+ case op
1503
+ when 10 # Hello
1504
+ heartbeat_interval = data["heartbeat_interval"]
1505
+ LOG.debug "[Discord:#{agent_display}] Gateway connected, heartbeat: #{heartbeat_interval}ms"
1506
+
1507
+ heartbeat_thread&.kill
1508
+ heartbeat_thread = Thread.new do
1509
+ loop do
1510
+ sleep(heartbeat_interval / 1000.0)
1511
+ ws.send({ op: 1, d: last_sequence }.to_json)
1512
+ end
1513
+ end
1514
+
1515
+ ws.send({
1516
+ op: 2,
1517
+ d: {
1518
+ token: bot_token,
1519
+ intents: 46_593,
1520
+ properties: { os: RUBY_PLATFORM, browser: "zillacore", device: "zillacore" }
1521
+ }
1522
+ }.to_json)
1523
+
1524
+ when 0 # Dispatch
1525
+ case payload["t"]
1526
+ when "READY"
1527
+ bot_user_id = data.dig("user", "id")
1528
+ DISCORD_BOTS_MUTEX.synchronize do
1529
+ DISCORD_BOTS[agent_key][:user_id] = bot_user_id
1530
+ DISCORD_BOTS[agent_key][:status] = "ready"
1531
+ end
1532
+ guild_count = data["guilds"]&.size || 0
1533
+ LOG.info "[Discord] #{agent_display} ready (#{guild_count} #{guild_count == 1 ? "guild" : "guilds"})"
1534
+ LOG.debug "[Discord:#{agent_display}] user_id=#{bot_user_id}"
1535
+
1536
+ # Check if all bots are now ready (log once)
1537
+ DISCORD_BOTS_MUTEX.synchronize do
1538
+ if !DISCORD_ALL_READY_LOGGED[:done] && DISCORD_BOTS.all? { |_, info| info[:status] == "ready" }
1539
+ DISCORD_ALL_READY_LOGGED[:done] = true
1540
+ LOG.info "[Discord] All bots connected."
1541
+ end
1542
+ end
1543
+
1544
+ when "MESSAGE_CREATE"
1545
+ Thread.new do
1546
+ handle_discord_message(data, agent_key, bot_token, bot_user_id)
1547
+ rescue StandardError => e
1548
+ LOG.error "[Discord:#{agent_display}] Error handling message: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
1549
+ end
1550
+
1551
+ when "MESSAGE_UPDATE"
1552
+ # Discord sends MESSAGE_UPDATE for embed/link preview resolution,
1553
+ # not just human edits. Only dispatch if edited_timestamp is set
1554
+ # (real edit) — otherwise it's just Discord enriching the message.
1555
+ if data["edited_timestamp"]
1556
+ Thread.new do
1557
+ handle_discord_message(data, agent_key, bot_token, bot_user_id)
1558
+ rescue StandardError => e
1559
+ LOG.error "[Discord:#{agent_display}] Error handling message update: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
1560
+ end
1561
+ end
1562
+
1563
+ when "MESSAGE_REACTION_ADD"
1564
+ Thread.new do
1565
+ handle_discord_reaction(data, agent_key, bot_token, bot_user_id)
1566
+ rescue StandardError => e
1567
+ LOG.error "[Discord:#{agent_display}] Error handling reaction: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
1568
+ end
1569
+ end
1570
+
1571
+ when 1 then ws.send({ op: 1, d: last_sequence }.to_json)
1572
+ when 7 then LOG.info "[Discord:#{agent_display}] Reconnect requested"
1573
+ ws.close
1574
+ when 9 then LOG.warn "[Discord:#{agent_display}] Invalid session, re-identifying in 5s"
1575
+ sleep 5
1576
+ ws.send({ op: 2, d: { token: bot_token, intents: 46_593,
1577
+ properties: { os: RUBY_PLATFORM, browser: "zillacore", device: "zillacore" } } }.to_json)
1578
+ when 11 then nil # Heartbeat ACK
1579
+ end
1580
+ rescue StandardError => e
1581
+ LOG.error "[Discord:#{agent_display}] Gateway message error: #{e.message}"
1582
+ end
1583
+
1584
+ ws.on :open do
1585
+ LOG.debug "[Discord:#{agent_display}] WebSocket connected"
1586
+ end
1587
+
1588
+ ws.on :close do |e|
1589
+ DISCORD_BOTS_MUTEX.synchronize do
1590
+ DISCORD_BOTS[agent_key][:status] = "disconnected" if DISCORD_BOTS[agent_key]
1591
+ end
1592
+ LOG.warn "[Discord:#{agent_display}] WebSocket closed: #{e&.inspect}"
1593
+ heartbeat_thread&.kill
1594
+ end
1595
+
1596
+ ws.on :error do |e|
1597
+ LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.message}"
1598
+ end
1599
+
1600
+ loop do
1601
+ sleep 1
1602
+ next if ws.open?
1603
+
1604
+ LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..."
1605
+ sleep 5
1606
+ break
1607
+ end
1608
+ rescue StandardError => e
1609
+ DISCORD_BOTS_MUTEX.synchronize do
1610
+ DISCORD_BOTS[agent_key][:status] = "error" if DISCORD_BOTS[agent_key]
1611
+ end
1612
+ LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in 5s..."
1613
+ sleep 5
1614
+ end
1615
+ end
1616
+ end
1617
+
1618
+ # Start all per-agent Discord bots.
1619
+ def start_all_discord_gateways
1620
+ tokens = discord_bot_tokens
1621
+ if tokens.empty?
1622
+ LOG.info "[Discord] No agents have DISCORD_BOT_TOKEN configured — Discord disabled"
1623
+ return
1624
+ end
1625
+
1626
+ LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}"
1627
+ tokens.each do |agent_key, token|
1628
+ DISCORD_BOTS_MUTEX.synchronize do
1629
+ DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
1630
+ end
1631
+ start_discord_gateway_for(agent_key, token)
1632
+ sleep 1 # Stagger connections to avoid rate limits
1633
+ end
1634
+ end
1635
+
1636
+ # Summary of all bot statuses for the API endpoint.
1637
+ def discord_bots_status
1638
+ DISCORD_BOTS_MUTEX.synchronize do
1639
+ DISCORD_BOTS.transform_values do |info|
1640
+ { status: info[:status], user_id: info[:user_id] }
1641
+ end
1642
+ end
1643
+ end