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
data/receiver.rb ADDED
@@ -0,0 +1,956 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # ZillaCore — modular webhook receiver
4
+ #
5
+ # This is the thin entry point. All logic lives in lib/zillacore/*.
6
+ # Start with: ruby receiver.rb
7
+
8
+ require "sinatra"
9
+ require "json"
10
+
11
+ # Load all modules
12
+ require_relative "lib/zillacore/config"
13
+ require_relative "lib/zillacore/users"
14
+ require_relative "lib/zillacore/agents"
15
+ require_relative "lib/zillacore/brain"
16
+ require_relative "lib/zillacore/skills"
17
+ require_relative "lib/zillacore/sessions"
18
+ require_relative "lib/zillacore/prompts"
19
+ require_relative "lib/zillacore/planning"
20
+ require_relative "lib/zillacore/helpers"
21
+ require_relative "lib/zillacore/cron"
22
+ require_relative "lib/zillacore/handlers/fizzy"
23
+ require_relative "lib/zillacore/handlers/github"
24
+ require_relative "lib/zillacore/card_index"
25
+ require_relative "lib/zillacore/deployments"
26
+
27
+ # Reload hook registry — custom handlers register callbacks here
28
+ module ReloadHooks
29
+ @hooks = []
30
+
31
+ def self.register(name, &block)
32
+ @hooks << { name: name, block: block }
33
+ end
34
+
35
+ def self.run_all!
36
+ @hooks.each { |hook| hook[:block].call }
37
+ end
38
+ end
39
+
40
+ def register_reload_hook(name, &)
41
+ ReloadHooks.register(name, &)
42
+ end
43
+
44
+ # Load custom handlers from ~/.zillacore/handlers/ (plugin system)
45
+ CUSTOM_HANDLERS_DIR = File.join(ZILLACORE_DIR, "handlers")
46
+ if Dir.exist?(CUSTOM_HANDLERS_DIR)
47
+ Dir.glob(File.join(CUSTOM_HANDLERS_DIR, "*.rb")).each do |handler|
48
+ LOG.info "[Handlers] Loading custom handler: #{File.basename(handler)}"
49
+ require handler
50
+ end
51
+ end
52
+
53
+ if DISCORD_ENABLED
54
+ require_relative "lib/zillacore/handlers/discord"
55
+ require_relative "lib/zillacore/handlers/zoho"
56
+ end
57
+
58
+ # --- Sinatra config ---
59
+ set :host_authorization, { permit_all: true }
60
+
61
+ # Disable Sinatra's default logging (we use SelectiveLogger instead)
62
+ set :logging, false
63
+
64
+ # Suppress Sinatra/Puma startup banners — we log our own version line
65
+ disable :show_exceptions
66
+ set :quiet, true
67
+ set :server_settings, { Silent: true }
68
+
69
+ # Custom logger that filters polling endpoints unless LOG_LEVEL=debug
70
+ SILENT_POLL_PATHS = %w[/api/status /api/deployments].freeze
71
+
72
+ class SelectiveLogger < Rack::CommonLogger
73
+ def call(env)
74
+ if SILENT_POLL_PATHS.include?(env["PATH_INFO"]) && LOG.level > Logger::DEBUG
75
+ @app.call(env)
76
+ else
77
+ super
78
+ end
79
+ end
80
+ end
81
+
82
+ configure do
83
+ use SelectiveLogger, LOG
84
+ end
85
+
86
+ LOG.info "[ZillaCore] Starting v#{ZILLACORE_VERSION} on port #{settings.port} (#{settings.environment})"
87
+
88
+ # --- Dashboard authentication ---
89
+
90
+ helpers do
91
+ def authenticate_dashboard!
92
+ return unless DASHBOARD_TOKEN # No token configured = no auth (local-only mode)
93
+
94
+ provided = params["token"] || request.env["HTTP_AUTHORIZATION"]&.sub(/^Bearer /i, "")
95
+ halt 401, "Unauthorized" unless provided == DASHBOARD_TOKEN
96
+ end
97
+
98
+ def localhost_request?
99
+ host = request.env["HTTP_HOST"].to_s
100
+ host.include?("localhost") || host.include?("127.0.0.1")
101
+ end
102
+ end
103
+
104
+ before "/dashboard" do
105
+ authenticate_dashboard!
106
+ end
107
+
108
+ before "/api/*" do
109
+ # Skip auth for all localhost requests (CLI, waybar, daemon, etc.)
110
+ pass if localhost_request?
111
+ # Skip auth for webhook-related routes that have their own verification
112
+ pass if request.path_info == "/api/discord"
113
+ authenticate_dashboard!
114
+ end
115
+
116
+ # --- Fizzy webhook routes ---
117
+
118
+ post "/fizzy/?:board_key?" do
119
+ content_type :json
120
+ request.body.rewind
121
+ payload_body = request.body.read
122
+ board_key = params["board_key"]
123
+
124
+ verify_signature!(request, payload_body, board_key: board_key)
125
+
126
+ payload = JSON.parse(payload_body)
127
+
128
+ event_id = payload["id"]
129
+ action = payload["action"]
130
+
131
+ LOG.info "[Fizzy] Received event #{event_id}: action=#{action}"
132
+
133
+ if already_processed?(event_id)
134
+ LOG.info "Skipping duplicate event #{event_id}"
135
+ halt 200, { status: "duplicate" }.to_json
136
+ end
137
+
138
+ reload_projects!
139
+ reload_agent_registry!
140
+ reload_github_config!
141
+
142
+ case action
143
+ when "card_assigned"
144
+ status_code, body = handle_card_assigned(payload)
145
+ LOG.info "[Fizzy] #{action} response: #{status_code} - #{body}"
146
+ halt status_code, body
147
+ when "comment_created"
148
+ status_code, body = handle_comment(payload)
149
+ LOG.info "[Fizzy] comment_created response: #{status_code} - #{body}"
150
+ halt status_code, body
151
+ when "card_published", "card_triaged"
152
+ eventable = payload["eventable"] || {}
153
+ card_number = eventable["number"]&.to_s
154
+
155
+ # card_triaged never dispatches agents — only card_assigned and @mentions do that.
156
+ # Guards remain as defense-in-depth for any future column-based routing.
157
+ if action == "card_triaged" && card_number
158
+ if self_move_recent?(card_number)
159
+ LOG.info "[Fizzy] Ignoring card_triaged for ##{card_number} — self-move echo"
160
+ halt 200, { status: "ignored", reason: "self_move" }.to_json
161
+ end
162
+
163
+ if card_merged?(card_number)
164
+ LOG.info "[Fizzy] Ignoring card_triaged for ##{card_number} — card already merged"
165
+ halt 200, { status: "ignored", reason: "card_merged" }.to_json
166
+ end
167
+
168
+ card_key = "card-#{card_number}"
169
+ if recently_completed?(card_key)
170
+ LOG.info "[Fizzy] Ignoring card_triaged for ##{card_number} — recently completed"
171
+ halt 200, { status: "ignored", reason: "recently_completed" }.to_json
172
+ end
173
+ end
174
+
175
+ # Only card_published does duplicate detection — card_triaged skips agent dispatch entirely
176
+ if action == "card_published"
177
+ assignees = eventable["assignees"] || []
178
+ if assignees.any? { |a| local_agent_names.include?(a["name"]) }
179
+ status_code, body = handle_card_assigned(payload)
180
+ LOG.info "[Fizzy] #{action} (with assignee) response: #{status_code} - #{body}"
181
+ halt status_code, body
182
+ end
183
+ end
184
+
185
+ status_code, body = handle_card_published(payload)
186
+ LOG.info "[Fizzy] #{action} response: #{status_code} - #{body}"
187
+ halt status_code, body
188
+ else
189
+ LOG.info "[Fizzy] Ignoring unknown action: #{action}"
190
+ halt 200, { status: "ignored", action: action }.to_json
191
+ end
192
+ rescue JSON::ParserError => e
193
+ LOG.error "Invalid JSON: #{e.message}"
194
+ halt 400, { error: "Invalid JSON" }.to_json
195
+ rescue StandardError => e
196
+ LOG.error "Unhandled error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
197
+ halt 500, { error: e.message }.to_json
198
+ end
199
+
200
+ post "/github" do
201
+ content_type :json
202
+ request.body.rewind
203
+ payload_body = request.body.read
204
+
205
+ verify_github_signature!(request, payload_body)
206
+
207
+ payload = JSON.parse(payload_body)
208
+ event = request.env["HTTP_X_GITHUB_EVENT"]
209
+
210
+ reload_projects!
211
+ reload_agent_registry!
212
+ reload_github_config!
213
+
214
+ action = payload["action"]
215
+
216
+ case event
217
+ when "pull_request"
218
+ if action == "closed" && payload.dig("pull_request", "merged")
219
+ status_code, body = handle_github_pr_merged(payload)
220
+ halt status_code, body
221
+ elsif action == "opened"
222
+ track_pr_in_card_map(payload)
223
+ halt 200, { status: "processed", action: "pr_tracked" }.to_json
224
+ elsif action == "synchronize"
225
+ status_code, body = handle_github_pr_synchronized(payload)
226
+ halt status_code, body
227
+ else
228
+ halt 200, { status: "ignored", reason: "pull_request action: #{action}" }.to_json
229
+ end
230
+ when "pull_request_review"
231
+ if action == "submitted"
232
+ status_code, body = handle_github_pr_review_submitted(payload)
233
+ halt status_code, body
234
+ else
235
+ halt 200, { status: "ignored", reason: "pull_request_review action: #{action}" }.to_json
236
+ end
237
+ when "issue_comment"
238
+ if action == "created"
239
+ status_code, body = handle_github_issue_comment(payload)
240
+ halt status_code, body
241
+ else
242
+ halt 200, { status: "ignored", reason: "issue_comment action: #{action}" }.to_json
243
+ end
244
+ when "issues"
245
+ if action == "opened"
246
+ status_code, body = handle_github_issue_opened(payload)
247
+ halt status_code, body
248
+ else
249
+ halt 200, { status: "ignored", reason: "issues action: #{action}" }.to_json
250
+ end
251
+ when "workflow_run"
252
+ if action == "completed"
253
+ status_code, body = handle_github_workflow_run(payload)
254
+ halt status_code, body
255
+ else
256
+ halt 200, { status: "ignored", reason: "workflow_run action: #{action}" }.to_json
257
+ end
258
+ when "ping"
259
+ halt 200, { status: "pong" }.to_json
260
+ else
261
+ halt 200, { status: "ignored", event: event }.to_json
262
+ end
263
+ rescue JSON::ParserError => e
264
+ LOG.error "Invalid JSON: #{e.message}"
265
+ halt 400, { error: "Invalid JSON" }.to_json
266
+ rescue StandardError => e
267
+ LOG.error "Unhandled error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
268
+ halt 500, { error: e.message }.to_json
269
+ end
270
+
271
+ # --- Zoho Mail webhook route ---
272
+
273
+ if DISCORD_ENABLED
274
+ post "/zoho" do
275
+ content_type :json
276
+ request.body.rewind
277
+ payload_body = request.body.read
278
+
279
+ # Zoho sends X-Hook-Secret on the very first request — capture and store it
280
+ hook_secret = request.env["HTTP_X_HOOK_SECRET"]
281
+ if hook_secret
282
+ save_zoho_hook_secret(hook_secret)
283
+ LOG.info "[Zoho] Received and stored hook_secret from initial handshake"
284
+ halt 200, { status: "hook_secret_received" }.to_json
285
+ end
286
+
287
+ verify_zoho_signature!(request, payload_body)
288
+
289
+ email = JSON.parse(payload_body)
290
+ LOG.info "[Zoho] Received email: subject=#{email["subject"]}, from=#{email["fromAddress"]}, to=#{email["toAddress"]}"
291
+ LOG.info "[Zoho] Payload keys: #{email.keys.sort.join(", ")}"
292
+ LOG.info "[Zoho] summary=#{email["summary"].to_s[0..200].inspect}, html=#{email["html"].to_s[0..200].inspect}, content=#{email["content"].to_s[0..200].inspect}"
293
+
294
+ # Dump raw payload for debugging (last 5 kept)
295
+ zoho_debug_dir = File.join(ZILLACORE_DIR, "tmp", "zoho", "payloads")
296
+ FileUtils.mkdir_p(zoho_debug_dir)
297
+ File.write(File.join(zoho_debug_dir, "#{Time.now.strftime("%Y%m%d-%H%M%S")}.json"), JSON.pretty_generate(email))
298
+
299
+ reload_zoho_config!
300
+ rule = match_zoho_rule(email)
301
+
302
+ if rule
303
+ LOG.info "[Zoho] Matched rule: #{rule["label"]}"
304
+ if rule["dispatch_agent"]
305
+ dispatch_zoho_triage(email, rule)
306
+ halt 200, { status: "triage_dispatched", rule: rule["label"], agent: rule["dispatch_agent"] }.to_json
307
+ else
308
+ notify_zoho_match(email, rule)
309
+ halt 200, { status: "matched", rule: rule["label"] }.to_json
310
+ end
311
+ else
312
+ LOG.info "[Zoho] No rules matched"
313
+ halt 200, { status: "no_match" }.to_json
314
+ end
315
+ rescue JSON::ParserError => e
316
+ LOG.error "[Zoho] Invalid JSON: #{e.message}"
317
+ halt 400, { error: "Invalid JSON" }.to_json
318
+ rescue StandardError => e
319
+ LOG.error "[Zoho] Unhandled error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
320
+ halt 500, { error: e.message }.to_json
321
+ end
322
+ end
323
+
324
+ # --- Zoho OAuth routes ---
325
+
326
+ if DISCORD_ENABLED
327
+ ZOHO_AUTH_SCOPES = "ZohoMail.messages.READ,ZohoMail.accounts.READ,ZohoMail.folders.READ".freeze
328
+
329
+ get "/zoho/auth" do
330
+ reload_zoho_config!
331
+ api = ZOHO_CONFIG["api"] || {}
332
+ halt 500, "Missing client_id in zoho.json api section" unless api["client_id"]
333
+
334
+ redirect_uri = "#{request.base_url}/zoho/callback"
335
+ params = URI.encode_www_form(
336
+ scope: ZOHO_AUTH_SCOPES,
337
+ client_id: api["client_id"],
338
+ response_type: "code",
339
+ access_type: "offline",
340
+ redirect_uri: redirect_uri,
341
+ prompt: "consent"
342
+ )
343
+ redirect "https://accounts.zoho.com/oauth/v2/auth?#{params}"
344
+ end
345
+
346
+ get "/zoho/callback" do
347
+ content_type :html
348
+ code = params["code"]
349
+ halt 400, "No authorization code received" unless code
350
+
351
+ reload_zoho_config!
352
+ api = ZOHO_CONFIG["api"] || {}
353
+ redirect_uri = "#{request.base_url}/zoho/callback"
354
+
355
+ uri = URI(ZOHO_TOKEN_URL)
356
+ res = Net::HTTP.post_form(uri, {
357
+ "grant_type" => "authorization_code",
358
+ "client_id" => api["client_id"],
359
+ "client_secret" => api["client_secret"],
360
+ "code" => code,
361
+ "redirect_uri" => redirect_uri
362
+ })
363
+
364
+ data = JSON.parse(res.body)
365
+ if data["refresh_token"]
366
+ ZOHO_CONFIG["api"] ||= {}
367
+ ZOHO_CONFIG["api"]["refresh_token"] = data["refresh_token"]
368
+ @zoho_access_token = data["access_token"]
369
+ @zoho_token_expires_at = Time.now + 3300
370
+ File.write(ZOHO_CONFIG_FILE, JSON.pretty_generate(ZOHO_CONFIG))
371
+ LOG.info "[Zoho:OAuth] Stored refresh_token and access_token"
372
+
373
+ # Auto-fetch account_id if not set
374
+ unless ZOHO_CONFIG.dig("api", "account_id")
375
+ acct_uri = URI(ZOHO_MAIL_API_BASE.to_s)
376
+ http = Net::HTTP.new(acct_uri.host, acct_uri.port)
377
+ http.use_ssl = true
378
+ req = Net::HTTP::Get.new(acct_uri)
379
+ req["Authorization"] = "Zoho-oauthtoken #{@zoho_access_token}"
380
+ acct_res = http.request(req)
381
+ acct_data = JSON.parse(acct_res.body)
382
+ if (account_id = acct_data.dig("data", 0, "accountId"))
383
+ ZOHO_CONFIG["api"]["account_id"] = account_id
384
+ File.write(ZOHO_CONFIG_FILE, JSON.pretty_generate(ZOHO_CONFIG))
385
+ LOG.info "[Zoho:OAuth] Auto-fetched account_id: #{account_id}"
386
+ end
387
+ end
388
+
389
+ "<h1>✅ Zoho OAuth Complete</h1><p>Refresh token and account_id saved to zoho.json. You can close this tab.</p>"
390
+ else
391
+ LOG.error "[Zoho:OAuth] Token exchange failed: #{data}"
392
+ "<h1>❌ OAuth Failed</h1><pre>#{data.to_json}</pre>"
393
+ end
394
+ rescue StandardError => e
395
+ LOG.error "[Zoho:OAuth] Error: #{e.message}"
396
+ "<h1>❌ Error</h1><pre>#{e.message}</pre>"
397
+ end
398
+ end
399
+
400
+ # --- Admin API routes ---
401
+
402
+ get "/api/projects" do
403
+ content_type :json
404
+ reload_projects!
405
+ { projects: PROJECTS }.to_json
406
+ end
407
+
408
+ get "/api/projects/:key" do
409
+ content_type :json
410
+ reload_projects!
411
+ project_key = params["key"]
412
+ if PROJECTS.key?(project_key)
413
+ { project: PROJECTS[project_key] }.to_json
414
+ else
415
+ halt 404, { error: "Project not found" }.to_json
416
+ end
417
+ end
418
+
419
+ post "/api/reload" do
420
+ content_type :json
421
+ reload_projects!(force: true)
422
+ reload_agent_registry!(force: true)
423
+ reload_user_registry!(force: true)
424
+ reload_github_config!(force: true)
425
+ reload_deployments_config!(force: true)
426
+ ReloadHooks.run_all!
427
+ { status: "reloaded", projects: PROJECTS.keys, agents: all_agent_names.to_a, registry: AGENT_REGISTRY.keys,
428
+ users: USER_REGISTRY["users"].size }.to_json
429
+ end
430
+
431
+ get "/api/agents" do
432
+ content_type :json
433
+ { default: AI_AGENT_NAME, agents: discover_kiro_agents, all_known: all_agent_names.to_a, roster: agent_roster }.to_json
434
+ end
435
+
436
+ get "/api/users" do
437
+ content_type :json
438
+ reload_user_registry!
439
+
440
+ filter = params["filter"]
441
+ users = case filter
442
+ when "humans" then human_users
443
+ when "agents" then ai_agents
444
+ else USER_REGISTRY["users"]
445
+ end
446
+
447
+ { users: users, total: USER_REGISTRY["users"].size, schema_version: USER_REGISTRY["schema_version"] }.to_json
448
+ end
449
+
450
+ get "/api/users/:identifier" do
451
+ content_type :json
452
+ reload_user_registry!
453
+
454
+ identifier = params["identifier"]
455
+ user = find_user(identifier)
456
+
457
+ if user
458
+ { user: user }.to_json
459
+ else
460
+ halt 404, { error: "User not found", identifier: identifier }.to_json
461
+ end
462
+ end
463
+
464
+ # --- Brain API routes ---
465
+
466
+ get "/api/brain" do
467
+ content_type :json
468
+ agent = params["agent"] || AI_AGENT_NAME
469
+ persona_dir = persona_dir_for(agent)
470
+ persona_col = persona_collection_for(agent)
471
+
472
+ knowledge_files = File.directory?(KNOWLEDGE_DIR) ? Dir.glob(File.join(KNOWLEDGE_DIR, "**", "*.md")).map { |f| f.sub("#{KNOWLEDGE_DIR}/", "") } : []
473
+ persona_files = File.directory?(persona_dir) ? Dir.glob(File.join(persona_dir, "**", "*.md")).map { |f| f.sub("#{persona_dir}/", "") } : []
474
+
475
+ {
476
+ agent: agent,
477
+ knowledge: { dir: KNOWLEDGE_DIR, collection: KNOWLEDGE_COLLECTION, files: knowledge_files },
478
+ persona: { dir: persona_dir, collection: persona_col, files: persona_files }
479
+ }.to_json
480
+ end
481
+
482
+ get "/api/brain/search" do
483
+ content_type :json
484
+ query = params["q"]
485
+ halt 400, { error: "Missing query parameter ?q=" }.to_json unless query && !query.empty?
486
+
487
+ agent = params["agent"] || AI_AGENT_NAME
488
+ scope = (params["scope"] || "knowledge").to_sym
489
+ scope = :knowledge unless %i[knowledge persona].include?(scope)
490
+ results = query_brain(query, agent_name: agent, scope: scope, max_results: (params["n"] || 5).to_i)
491
+
492
+ { agent: agent, scope: scope, query: query, results: results }.to_json
493
+ end
494
+
495
+ get "/api/skills" do
496
+ content_type :json
497
+ skills = build_skill_index
498
+ { total: skills.size, skills: skills }.to_json
499
+ end
500
+
501
+ post "/api/skills/curate" do
502
+ content_type :json
503
+ result = curate_skills
504
+ result.to_json
505
+ end
506
+
507
+ get "/api/card-index" do
508
+ content_type :json
509
+ query = params["q"]
510
+ if query && !query.empty?
511
+ similar = CARD_INDEX.find_similar_cards(query)
512
+ { query: query, matches: similar, total_indexed: CARD_INDEX.size }.to_json
513
+ else
514
+ { total: CARD_INDEX.size, cards: CARD_INDEX }.to_json
515
+ end
516
+ end
517
+
518
+ get "/api/dispatch-depth" do
519
+ content_type :json
520
+ {
521
+ max_depth: AGENT_DISPATCH_MAX_DEPTH,
522
+ window_seconds: AGENT_DISPATCH_WINDOW,
523
+ cards: AGENT_DISPATCH_DEPTH.transform_values do |v|
524
+ { count: v[:count], last_human_at: v[:last_human_at]&.iso8601, blocked: v[:count] >= AGENT_DISPATCH_MAX_DEPTH }
525
+ end
526
+ }.to_json
527
+ end
528
+
529
+ get "/api/status" do
530
+ content_type :json
531
+ ACTIVE_SESSIONS_MUTEX.synchronize do
532
+ # Clean up stale sessions first
533
+ ACTIVE_SESSIONS.delete_if do |card_key, info|
534
+ Process.kill(0, info[:pid])
535
+ false # Keep alive sessions
536
+ rescue Errno::ESRCH, Errno::EPERM
537
+ archive_session(card_key, info)
538
+ true # Remove dead sessions
539
+ end
540
+
541
+ sessions = ACTIVE_SESSIONS.map do |card_key, info|
542
+ # Use stored agent_name if available, otherwise try to extract from card_key
543
+ agent_name = if info[:agent_name]
544
+ info[:agent_name]
545
+ else
546
+ # Fallback: extract from card_key for backwards compatibility
547
+ # Formats: "discord-AGENT-CHANNEL-MESSAGE" or "card-ID"
548
+ parts = card_key.split("-")
549
+ agent_key = if parts[0] == "discord" && parts.size >= 4
550
+ parts[1] # Second part is agent name
551
+ else
552
+ "Unknown"
553
+ end
554
+ fizzy_display_name(agent_key)
555
+ end
556
+
557
+ {
558
+ card_key: card_key,
559
+ agent: agent_name,
560
+ pid: info[:pid],
561
+ started_at: info[:started_at].iso8601,
562
+ elapsed_seconds: (Time.now - info[:started_at]).to_i,
563
+ log_file: info[:log_file],
564
+ alive: true,
565
+ children: child_processes_for(info[:pid])
566
+ }
567
+ end
568
+
569
+ recent = RECENT_SESSIONS.map do |s|
570
+ {
571
+ card_key: s[:card_key],
572
+ agent: s[:agent_name] || "Unknown",
573
+ log_file: s[:log_file],
574
+ started_at: s[:started_at]&.iso8601,
575
+ finished_at: s[:finished_at]&.iso8601
576
+ }
577
+ end
578
+
579
+ { sessions: sessions, count: sessions.size, recent: recent, version: ZILLACORE_VERSION }.to_json
580
+ end
581
+ end
582
+
583
+ # Kill an entire agent session (parent process + all children)
584
+ post "/api/sessions/kill/:card_key" do
585
+ content_type :json
586
+ card_key = params[:card_key]
587
+ halt 400, { error: "missing card_key" }.to_json if card_key.to_s.empty?
588
+
589
+ killed = kill_session(card_key)
590
+ halt 404, { error: "session not found" }.to_json unless killed
591
+
592
+ LOG.info "Killed agent session #{card_key} via API"
593
+ { killed: card_key }.to_json
594
+ end
595
+
596
+ # Kill a specific child process of an active agent session
597
+ post "/api/sessions/kill-process/:pid" do
598
+ content_type :json
599
+ target_pid = params[:pid].to_i
600
+ halt 400, { error: "invalid pid" }.to_json if target_pid <= 0
601
+
602
+ # Verify the target PID is actually a descendant of an active session
603
+ valid = ACTIVE_SESSIONS_MUTEX.synchronize do
604
+ ACTIVE_SESSIONS.any? do |_, info|
605
+ child_processes_for(info[:pid]).any? { |c| c[:pid] == target_pid }
606
+ end
607
+ end
608
+ halt 403, { error: "pid is not a child of any active agent session" }.to_json unless valid
609
+
610
+ begin
611
+ Process.kill("TERM", target_pid)
612
+ # Give it a moment, then force kill if still alive
613
+ Thread.new do
614
+ sleep 3
615
+ begin
616
+ Process.kill(0, target_pid)
617
+ Process.kill("KILL", target_pid)
618
+ rescue Errno::ESRCH, Errno::EPERM # rubocop:disable Lint/SuppressedException
619
+ end
620
+ end
621
+ LOG.info "Killed child process #{target_pid} (SIGTERM)"
622
+ { killed: target_pid }.to_json
623
+ rescue Errno::ESRCH
624
+ halt 404, { error: "process not found" }.to_json
625
+ rescue Errno::EPERM
626
+ halt 403, { error: "permission denied" }.to_json
627
+ end
628
+ end
629
+
630
+ # --- Dashboard ---
631
+
632
+ WAYBAR_CONFIG_PATH = File.expand_path("~/.zillacore/waybar.json")
633
+
634
+ def load_dashboard_agents
635
+ return {} unless File.exist?(WAYBAR_CONFIG_PATH)
636
+
637
+ config = JSON.parse(File.read(WAYBAR_CONFIG_PATH))
638
+ agents = {}
639
+ (config["agents"] || []).each { |a| agents[a["name"].downcase] = { emoji: a["emoji"], color: a["color"] } }
640
+ agents
641
+ rescue StandardError
642
+ {}
643
+ end
644
+
645
+ get "/dashboard" do
646
+ content_type :html
647
+ erb :dashboard, layout: false
648
+ end
649
+
650
+ get "/api/logs" do
651
+ content_type "text/plain"
652
+ log_file = params["file"]
653
+ lines = (params["lines"] || 200).to_i
654
+
655
+ halt 400, "Missing ?file= parameter" unless log_file && !log_file.empty?
656
+ halt 400, "Invalid path" if log_file.include?("..") || !log_file.start_with?("/")
657
+ halt 404, "File not found" unless File.exist?(log_file)
658
+
659
+ # Only allow reading log files from known project tmp dirs or zillacore tmp
660
+ allowed = PROJECTS.values.map { |p| File.join(p["repo_path"], "tmp") }
661
+ allowed << File.join(ZILLACORE_DIR, "tmp")
662
+ halt 403, "Forbidden" unless allowed.any? { |dir| log_file.start_with?(dir) }
663
+
664
+ # Read last N lines and strip ANSI escape codes
665
+ all_lines = File.readlines(log_file).last(lines)
666
+ all_lines.join.gsub(/\e\[[\d;]*[a-zA-Z]/, "").gsub(/\e\[\?[\d;]*[a-zA-Z]/, "")
667
+ end
668
+
669
+ # --- Discord API + startup ---
670
+
671
+ if DISCORD_ENABLED
672
+ get "/api/discord" do
673
+ content_type :json
674
+ {
675
+ enabled: true,
676
+ bots: discord_bots_status,
677
+ config: {
678
+ default_project: DISCORD_CONFIG["default_project"],
679
+ channel_mappings: DISCORD_CONFIG["channel_mappings"]&.size || 0,
680
+ authorized_users: (DISCORD_CONFIG["authorized_user_ids"] || []).size,
681
+ authorized_roles: (DISCORD_CONFIG["authorized_role_ids"] || []).size
682
+ }
683
+ }.to_json
684
+ end
685
+
686
+ start_all_discord_gateways
687
+ start_discord_draft_poller
688
+ start_zillacore_restart_monitor
689
+
690
+ # Send "back online" notification after bots connect (if restarted)
691
+ Thread.new do
692
+ # Wait for at least one bot to be ready (up to 30s)
693
+ 30.times do
694
+ sleep 1
695
+ ready = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.any? { |_, info| info[:status] == "ready" } }
696
+ next unless ready
697
+
698
+ send_restart_notification("✅ Zillacore back online")
699
+
700
+ # Check if running an outdated version (skip in dev/foreground mode)
701
+ unless $stdout.tty?
702
+ version_info = check_zillacore_version
703
+ if version_info[:behind]
704
+ owner_id = owner_discord_id
705
+ mention = owner_id ? "<@#{owner_id}>" : "Someone"
706
+ channel_id = DISCORD_CONFIG["notification_channel_id"]
707
+ tokens = discord_bot_tokens
708
+ token = tokens.values.first
709
+ if channel_id && token
710
+ send_discord_message(channel_id,
711
+ "#{mention}: Zillacore was updated and needs to be pulled down (#{version_info[:commits_behind]} commit#{"s" if version_info[:commits_behind] != 1} behind, running #{version_info[:local_sha]} vs #{version_info[:remote_sha]})",
712
+ token: token)
713
+ end
714
+ end
715
+ end
716
+
717
+ break
718
+ end
719
+ end
720
+ else
721
+ get "/api/discord" do
722
+ content_type :json
723
+ { enabled: false, reason: "websocket-client-simple gem not installed" }.to_json
724
+ end
725
+ end
726
+
727
+ # --- Cron API + startup ---
728
+
729
+ get "/api/cron/script" do
730
+ content_type "text/plain"
731
+ path = params["path"]
732
+ halt 400, "Missing ?path= parameter" unless path && !path.empty?
733
+ halt 400, "Invalid path" if path.include?("..")
734
+
735
+ # Only allow reading scripts that are actually referenced by a cron job
736
+ reload_cron_jobs!
737
+ valid = CRON_JOBS.values.any? do |j|
738
+ j[:script] == path || j["script"] == path ||
739
+ (j[:prompt] || j["prompt"] || "").include?(path)
740
+ end
741
+ halt 403, "Not a registered cron script" unless valid
742
+ halt 404, "File not found" unless File.exist?(path)
743
+
744
+ File.read(path)
745
+ end
746
+
747
+ get "/api/cron" do
748
+ content_type :json
749
+ reload_cron_jobs!
750
+ {
751
+ enabled: true,
752
+ jobs: CRON_JOBS,
753
+ thread_alive: CRON_THREAD[:ref]&.alive? || false
754
+ }.to_json
755
+ end
756
+
757
+ post "/api/cron/add" do
758
+ content_type :json
759
+ request.body.rewind
760
+ payload = JSON.parse(request.body.read)
761
+
762
+ result = add_cron_job(
763
+ id: payload["id"],
764
+ schedule: payload["schedule"],
765
+ agent: payload["agent"],
766
+ project: payload["project"],
767
+ prompt: payload["prompt"],
768
+ script: payload["script"],
769
+ model: payload["model"],
770
+ effort: payload["effort"],
771
+ discord_channel_id: payload["discord_channel_id"],
772
+ forum_title: payload["forum_title"],
773
+ forum_reply_to_latest: payload["forum_reply_to_latest"] || false,
774
+ repeat_count: payload["repeat_count"]
775
+ )
776
+
777
+ result.to_json
778
+ end
779
+
780
+ post "/api/cron/remove" do
781
+ content_type :json
782
+ request.body.rewind
783
+ payload = JSON.parse(request.body.read)
784
+
785
+ result = remove_cron_job(payload["id"])
786
+ result.to_json
787
+ end
788
+
789
+ post "/api/cron/toggle" do
790
+ content_type :json
791
+ request.body.rewind
792
+ payload = JSON.parse(request.body.read)
793
+
794
+ result = toggle_cron_job(payload["id"], payload["enabled"])
795
+ result.to_json
796
+ end
797
+
798
+ post "/api/cron/update" do
799
+ content_type :json
800
+ request.body.rewind
801
+ payload = JSON.parse(request.body.read)
802
+
803
+ result = update_cron_job(
804
+ payload["id"],
805
+ schedule: payload["schedule"],
806
+ discord_channel_id: payload["discord_channel_id"],
807
+ forum_title: payload["forum_title"],
808
+ forum_reply_to_latest: payload["forum_reply_to_latest"]
809
+ )
810
+ result.to_json
811
+ end
812
+
813
+ post "/api/cron/reload" do
814
+ content_type :json
815
+ reload_cron_jobs!
816
+ { status: "reloaded", jobs: CRON_JOBS.size }.to_json
817
+ end
818
+
819
+ get "/api/cron/logs" do
820
+ content_type :json
821
+ job_id = params["id"]
822
+ halt 400, { error: "Missing ?id= parameter" }.to_json unless job_id && !job_id.empty?
823
+
824
+ logs = []
825
+ PROJECTS.each_value do |proj|
826
+ tmp_dir = File.join(proj["repo_path"], "tmp")
827
+ next unless Dir.exist?(tmp_dir)
828
+
829
+ Dir.glob(File.join(tmp_dir, "{agent-cron,cron-script}-#{job_id}-*.log")).each do |f|
830
+ logs << { file: f, size: File.size(f), modified: File.mtime(f).iso8601 }
831
+ end
832
+ end
833
+ logs.sort_by! { |l| l[:modified] }.reverse!
834
+ logs.first(20).to_json
835
+ end
836
+
837
+ get "/api/gif" do
838
+ content_type :json
839
+ query = params[:q].to_s.strip
840
+ halt 400, { error: "Missing ?q= parameter" }.to_json if query.empty?
841
+
842
+ api_key = DISCORD_CONFIG["giphy_api_key"]
843
+ halt 503, { error: "No giphy_api_key configured in discord.json" }.to_json unless api_key
844
+
845
+ begin
846
+ uri = URI("https://api.giphy.com/v1/gifs/search")
847
+ uri.query = URI.encode_www_form(api_key: api_key, q: query, limit: 5, rating: "pg-13")
848
+ response = Net::HTTP.get_response(uri)
849
+
850
+ if response.code.to_i == 200
851
+ results = JSON.parse(response.body)["data"] || []
852
+ gifs = results.map { |g| { url: g.dig("images", "original", "url") || g["url"], title: g["title"] } }
853
+ { query: query, results: gifs }.to_json
854
+ else
855
+ LOG.warn "[GIF] Giphy API returned #{response.code}: #{response.body[0..200]}"
856
+ halt 502, { error: "Giphy API error: #{response.code}" }.to_json
857
+ end
858
+ rescue StandardError => e
859
+ LOG.error "[GIF] Search failed: #{e.message}"
860
+ halt 500, { error: e.message }.to_json
861
+ end
862
+ end
863
+
864
+ # --- Deployment environment tracking ---
865
+
866
+ get "/api/deployments" do
867
+ content_type :json
868
+ reload_deployments_config!
869
+ reload_deployment_state!
870
+ { deployments: deployment_status }.to_json
871
+ end
872
+
873
+ post "/api/deployments/:env" do
874
+ content_type :json
875
+ env_key = params["env"]
876
+ request.body.rewind
877
+ payload = JSON.parse(request.body.read)
878
+
879
+ result = deploy_to_environment(env_key, worktree_path: payload["worktree"], deployed_by: payload["deployed_by"])
880
+ if result[:error]
881
+ halt 404, result.to_json
882
+ else
883
+ { status: "deployed", env: env_key, deployment: result }.to_json
884
+ end
885
+ rescue JSON::ParserError
886
+ halt 400, { error: "Invalid JSON" }.to_json
887
+ end
888
+
889
+ delete "/api/deployments/:env" do
890
+ content_type :json
891
+ env_key = params["env"]
892
+ state = load_deployment_state
893
+ if state.key?(env_key)
894
+ state[env_key] = { "status" => "available", "cleared_at" => Time.now.iso8601, "last_card" => state[env_key]["card_number"] }
895
+ save_deployment_state(state)
896
+ DEPLOYMENT_STATE.replace(state)
897
+ LOG.info "[Deploy] Manually cleared #{env_key}"
898
+ { status: "cleared", env: env_key }.to_json
899
+ else
900
+ halt 404, { error: "Unknown environment: #{env_key}" }.to_json
901
+ end
902
+ end
903
+
904
+ post "/api/deployments/:env/deploying" do
905
+ content_type :json
906
+ env_key = params["env"]
907
+ config = DEPLOYMENTS_CONFIG["environments"] || {}
908
+ halt 404, { error: "Unknown environment: #{env_key}" }.to_json unless config.key?(env_key)
909
+ request.body.rewind
910
+ payload = begin
911
+ JSON.parse(request.body.read)
912
+ rescue StandardError
913
+ {}
914
+ end
915
+ mark_deploying(env_key, worktree_path: payload["worktree"] || "")
916
+ LOG.info "[Deploy] #{env_key} marked deploying via API"
917
+ { status: "deploying", env: env_key }.to_json
918
+ end
919
+
920
+ LOG.info "[Cron] Starting cron thread..."
921
+ start_cron_thread
922
+
923
+ # Skill curator: runs daily, archives stale skills, logs consolidation candidates.
924
+ CURATOR_THREAD = Thread.new do
925
+ loop do
926
+ sleep(86_400) # Run once per day
927
+ LOG.info "[Curator] Running scheduled skill curation..."
928
+ curate_skills
929
+ rescue StandardError => e
930
+ LOG.warn "[Curator] Error: #{e.message}"
931
+ end
932
+ end
933
+
934
+ LOG.info "[CardIndex] Starting background backfill..."
935
+ CARD_INDEX.backfill
936
+
937
+ LOG.info "[Monitor] Starting daemon..."
938
+ daemon_path = File.join(__dir__, "monitor", "daemon.rb")
939
+ daemon_pid_file = "/tmp/zillacore-daemon.pid"
940
+
941
+ # Kill old daemon if it exists
942
+ if File.exist?(daemon_pid_file)
943
+ old_pid = File.read(daemon_pid_file).strip.to_i
944
+ begin
945
+ Process.kill("TERM", old_pid)
946
+ LOG.info "[Monitor] Killed old daemon (PID #{old_pid})"
947
+ rescue Errno::ESRCH
948
+ LOG.debug "[Monitor] Old daemon PID #{old_pid} not running"
949
+ end
950
+ end
951
+
952
+ # Start new daemon
953
+ pid = spawn("ruby", daemon_path, chdir: __dir__, out: "/dev/null", err: "/dev/null")
954
+ File.write(daemon_pid_file, pid)
955
+ Process.detach(pid)
956
+ LOG.info "[Monitor] Daemon started (PID #{pid})"