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,629 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ZillaCore Cron — scheduled agent tasks
4
+ #
5
+ # Runs in a background thread, checks cron jobs every minute,
6
+ # dispatches agents with natural language prompts on schedule.
7
+
8
+ require "English"
9
+ require "time"
10
+ require "json"
11
+
12
+ CRON_CONFIG_FILE = File.join(ZILLACORE_DIR, "cron.json")
13
+ CRON_JOBS = {}
14
+ CRON_JOBS_MUTEX = Mutex.new
15
+ CRON_THREAD = { ref: nil }
16
+
17
+ # Parse cron expression (simplified: supports minute, hour, day, month, weekday)
18
+ # Format: "minute hour day month weekday" (e.g., "0 9 * * 1-5" = 9am weekdays)
19
+ # Also supports special strings: @hourly, @daily, @weekly, @monthly
20
+ # Also supports one-time timestamps: ISO8601 format (e.g., "2026-02-27T09:00:00-05:00")
21
+ # Also supports natural language: "tomorrow at 9am", "in 2 hours", "next monday at 3pm"
22
+ def parse_cron_expression(expr)
23
+ case expr
24
+ when "@hourly" then { minute: 0, hour: "*", day: "*", month: "*", weekday: "*" }
25
+ when "@daily" then { minute: 0, hour: 0, day: "*", month: "*", weekday: "*" }
26
+ when "@weekly" then { minute: 0, hour: 0, day: "*", month: "*", weekday: 0 }
27
+ when "@monthly" then { minute: 0, hour: 0, day: 1, month: "*", weekday: "*" }
28
+ else
29
+ # Try parsing as natural language or ISO8601 timestamp for one-time execution
30
+ timestamp = parse_natural_time(expr)
31
+ return { one_time: true, timestamp: timestamp } if timestamp
32
+
33
+ parts = expr.split
34
+ return nil unless parts.size == 5
35
+
36
+ { minute: parts[0], hour: parts[1], day: parts[2], month: parts[3], weekday: parts[4] }
37
+ end
38
+ end
39
+
40
+ # Parse natural language time expressions into absolute timestamps
41
+ def parse_natural_time(expr)
42
+ now = Time.now
43
+
44
+ # Try ISO8601 first
45
+ begin
46
+ return Time.parse(expr)
47
+ rescue ArgumentError
48
+ # Not ISO8601, try natural language
49
+ end
50
+
51
+ # "tomorrow at HH:MM" or "tomorrow at HHam/pm"
52
+ if expr =~ /^tomorrow\s+at\s+(.+)$/i
53
+ time_str = Regexp.last_match(1)
54
+ tomorrow = now + 86_400
55
+ parsed_time = parse_time_of_day(time_str, tomorrow)
56
+ return parsed_time if parsed_time
57
+ end
58
+
59
+ # "in X hours/minutes/days"
60
+ if expr =~ /^in\s+(\d+)\s+(hour|minute|day)s?$/i
61
+ amount = Regexp.last_match(1).to_i
62
+ unit = Regexp.last_match(2).downcase
63
+ case unit
64
+ when "minute" then return now + (amount * 60)
65
+ when "hour" then return now + (amount * 3600)
66
+ when "day" then return now + (amount * 86_400)
67
+ end
68
+ end
69
+
70
+ # "next monday/tuesday/etc at HH:MM"
71
+ weekdays = { "sunday" => 0, "monday" => 1, "tuesday" => 2, "wednesday" => 3,
72
+ "thursday" => 4, "friday" => 5, "saturday" => 6 }
73
+ if expr =~ /^next\s+(#{weekdays.keys.join("|")})\s+at\s+(.+)$/i
74
+ target_wday = weekdays[Regexp.last_match(1).downcase]
75
+ time_str = Regexp.last_match(2)
76
+ days_ahead = (target_wday - now.wday + 7) % 7
77
+ days_ahead = 7 if days_ahead.zero? # "next monday" means next week if today is monday
78
+ target_date = now + (days_ahead * 86_400)
79
+ parsed_time = parse_time_of_day(time_str, target_date)
80
+ return parsed_time if parsed_time
81
+ end
82
+
83
+ nil
84
+ end
85
+
86
+ # Parse time of day (e.g., "9am", "3:30pm", "14:00") and combine with a date
87
+ def parse_time_of_day(time_str, date)
88
+ # "9am" or "3pm"
89
+ if time_str =~ /^(\d+)(am|pm)$/i
90
+ hour = convert_meridiem_hour(Regexp.last_match(1).to_i, Regexp.last_match(2).downcase)
91
+ return Time.new(date.year, date.month, date.day, hour, 0, 0, date.utc_offset)
92
+ end
93
+
94
+ # "9:30am" or "3:45pm"
95
+ if time_str =~ /^(\d+):(\d+)(am|pm)$/i
96
+ hour = convert_meridiem_hour(Regexp.last_match(1).to_i, Regexp.last_match(3).downcase)
97
+ minute = Regexp.last_match(2).to_i
98
+ return Time.new(date.year, date.month, date.day, hour, minute, 0, date.utc_offset)
99
+ end
100
+
101
+ # "14:00" (24-hour format)
102
+ if time_str =~ /^(\d+):(\d+)$/
103
+ hour = Regexp.last_match(1).to_i
104
+ minute = Regexp.last_match(2).to_i
105
+ return Time.new(date.year, date.month, date.day, hour, minute, 0, date.utc_offset)
106
+ end
107
+
108
+ nil
109
+ end
110
+
111
+ # Convert 12-hour format hour + meridiem to 24-hour format.
112
+ def convert_meridiem_hour(hour, meridiem)
113
+ hour = 0 if hour == 12 && meridiem == "am"
114
+ hour += 12 if meridiem == "pm" && hour < 12
115
+ hour
116
+ end
117
+
118
+ # Check if current time matches cron expression
119
+ def cron_matches?(cron_hash, time = Time.now)
120
+ return false unless cron_hash
121
+
122
+ # Handle one-time scheduled tasks
123
+ if cron_hash[:one_time]
124
+ target = cron_hash[:timestamp]
125
+ # Match if we're within the same minute as the target time
126
+ return time.year == target.year &&
127
+ time.month == target.month &&
128
+ time.day == target.day &&
129
+ time.hour == target.hour &&
130
+ time.min == target.min
131
+ end
132
+
133
+ minute_match = match_field?(cron_hash[:minute], time.min)
134
+ hour_match = match_field?(cron_hash[:hour], time.hour)
135
+ day_match = match_field?(cron_hash[:day], time.day)
136
+ month_match = match_field?(cron_hash[:month], time.month)
137
+ weekday_match = match_field?(cron_hash[:weekday], time.wday)
138
+
139
+ minute_match && hour_match && day_match && month_match && weekday_match
140
+ end
141
+
142
+ def match_field?(pattern, value)
143
+ return true if pattern == "*"
144
+
145
+ # Handle ranges (e.g., "1-5")
146
+ if pattern.include?("-")
147
+ range_start, range_end = pattern.split("-").map(&:to_i)
148
+ return value.between?(range_start, range_end)
149
+ end
150
+
151
+ # Handle lists (e.g., "1,3,5")
152
+ return pattern.split(",").map(&:to_i).include?(value) if pattern.include?(",")
153
+
154
+ # Handle step values (e.g., "*/5")
155
+ if pattern.include?("/")
156
+ base, step = pattern.split("/")
157
+ step = step.to_i
158
+ return (value % step).zero? if base == "*"
159
+ end
160
+
161
+ # Exact match
162
+ pattern.to_i == value
163
+ end
164
+
165
+ # Load cron jobs from config
166
+ def load_cron_jobs
167
+ return {} unless File.exist?(CRON_CONFIG_FILE)
168
+
169
+ jobs = JSON.parse(File.read(CRON_CONFIG_FILE), symbolize_names: true)
170
+
171
+ # Deserialize timestamp strings back to Time objects for one-time jobs
172
+ jobs.each_value do |job|
173
+ next unless job[:parsed]
174
+
175
+ job[:parsed][:timestamp] = Time.parse(job[:parsed][:timestamp]) if job[:parsed][:one_time] && job[:parsed][:timestamp].is_a?(String)
176
+ end
177
+
178
+ jobs
179
+ rescue JSON::ParserError => e
180
+ LOG.error "[Cron] Failed to parse cron config: #{e.message}"
181
+ {}
182
+ end
183
+
184
+ # Save cron jobs to config
185
+ def save_cron_jobs(jobs)
186
+ FileUtils.mkdir_p(ZILLACORE_DIR)
187
+ File.write(CRON_CONFIG_FILE, JSON.pretty_generate(jobs))
188
+ end
189
+
190
+ # Reload cron jobs from disk
191
+ def reload_cron_jobs!(force: false)
192
+ return unless file_changed?(CRON_CONFIG_FILE, force: force)
193
+
194
+ CRON_JOBS_MUTEX.synchronize do
195
+ CRON_JOBS.clear
196
+ CRON_JOBS.merge!(load_cron_jobs)
197
+ end
198
+ LOG.info "[Cron] Reloaded #{CRON_JOBS.size} cron jobs"
199
+ end
200
+
201
+ # Add a new cron job
202
+ def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, discord_channel_id: nil,
203
+ forum_title: nil, forum_reply_to_latest: false, repeat_count: nil)
204
+ parsed = parse_cron_expression(schedule)
205
+ return { error: "Invalid cron expression" } unless parsed
206
+ return { error: "Must provide either prompt or script, not both" } if prompt && script
207
+ return { error: "Must provide either prompt or script" } unless prompt || script
208
+
209
+ job = {
210
+ id: id,
211
+ schedule: schedule,
212
+ parsed: parsed,
213
+ agent: agent,
214
+ project: project,
215
+ model: model,
216
+ effort: effort,
217
+ prompt: prompt,
218
+ script: script,
219
+ enabled: enabled,
220
+ discord_channel_id: discord_channel_id,
221
+ forum_title: forum_title,
222
+ forum_reply_to_latest: forum_reply_to_latest,
223
+ repeat_count: repeat_count,
224
+ execution_count: 0,
225
+ created_at: Time.now.iso8601,
226
+ last_run: nil
227
+ }
228
+
229
+ CRON_JOBS_MUTEX.synchronize do
230
+ jobs = load_cron_jobs
231
+ jobs[id.to_sym] = job
232
+ save_cron_jobs(jobs)
233
+ CRON_JOBS[id.to_sym] = job
234
+ end
235
+
236
+ { success: true, job: job }
237
+ end
238
+
239
+ # Remove a cron job
240
+ def remove_cron_job(id)
241
+ CRON_JOBS_MUTEX.synchronize do
242
+ jobs = load_cron_jobs
243
+ removed = jobs.delete(id.to_sym)
244
+ save_cron_jobs(jobs)
245
+ CRON_JOBS.delete(id.to_sym)
246
+ removed ? { success: true } : { error: "Job not found" }
247
+ end
248
+ end
249
+
250
+ # Enable/disable a cron job
251
+ def toggle_cron_job(id, enabled)
252
+ CRON_JOBS_MUTEX.synchronize do
253
+ jobs = load_cron_jobs
254
+ job = jobs[id.to_sym]
255
+ return { error: "Job not found" } unless job
256
+
257
+ job[:enabled] = enabled
258
+ jobs[id.to_sym] = job
259
+ save_cron_jobs(jobs)
260
+ CRON_JOBS[id.to_sym] = job
261
+ { success: true, job: job }
262
+ end
263
+ end
264
+
265
+ # Update a cron job's schedule, discord channel, and/or forum title
266
+ def update_cron_job(id, schedule: nil, discord_channel_id: nil, forum_title: nil, forum_reply_to_latest: nil)
267
+ return { error: "No updates provided" } if schedule.nil? && discord_channel_id.nil? && forum_title.nil? && forum_reply_to_latest.nil?
268
+
269
+ if schedule
270
+ parsed = parse_cron_expression(schedule)
271
+ return { error: "Invalid cron expression" } unless parsed
272
+ end
273
+
274
+ CRON_JOBS_MUTEX.synchronize do
275
+ jobs = load_cron_jobs
276
+ job = jobs[id.to_sym]
277
+ return { error: "Job not found" } unless job
278
+
279
+ if schedule
280
+ job[:schedule] = schedule
281
+ job[:parsed] = parsed
282
+ end
283
+ job[:discord_channel_id] = discord_channel_id if discord_channel_id
284
+ job[:forum_title] = forum_title if forum_title
285
+ job[:forum_reply_to_latest] = forum_reply_to_latest unless forum_reply_to_latest.nil?
286
+ jobs[id.to_sym] = job
287
+ save_cron_jobs(jobs)
288
+ CRON_JOBS[id.to_sym] = job
289
+ { success: true, job: job }
290
+ end
291
+ end
292
+
293
+ # Execute a script-based cron job (no agent, direct script execution)
294
+ def execute_script_job(job, project)
295
+ script_path = File.expand_path(job[:script])
296
+
297
+ unless File.exist?(script_path)
298
+ LOG.error "[Cron] Script not found: #{script_path}"
299
+ return
300
+ end
301
+
302
+ unless File.executable?(script_path)
303
+ LOG.error "[Cron] Script not executable: #{script_path}"
304
+ return
305
+ end
306
+
307
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
308
+ log_file = File.join(project["repo_path"], "tmp/cron-script-#{job[:id]}-#{timestamp}.log")
309
+ FileUtils.mkdir_p(File.dirname(log_file))
310
+
311
+ draft_file = prepare_script_discord_draft(job, timestamp) if job[:discord_channel_id]
312
+
313
+ LOG.info "[Cron] Running script #{script_path} for job #{job[:id]}, tail -f #{log_file}"
314
+
315
+ pid = spawn(script_path,
316
+ chdir: project["repo_path"],
317
+ out: [log_file, "w"],
318
+ err: %i[child out])
319
+
320
+ Thread.new do
321
+ Process.wait(pid)
322
+ LOG.info "[Cron] Script job #{job[:id]} finished (exit: #{$CHILD_STATUS.exitstatus})"
323
+ deliver_script_output(job, log_file, draft_file)
324
+ update_cron_job_state(job)
325
+ rescue StandardError => e
326
+ LOG.error "[Cron] Script job #{job[:id]} failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
327
+ end
328
+ end
329
+
330
+ # Prepare a Discord draft file and meta for a script job. Returns the draft file path.
331
+ def prepare_script_discord_draft(job, timestamp)
332
+ draft_file = File.join(DISCORD_DRAFT_DIR, "cron-script-#{timestamp}-#{job[:id]}.md")
333
+ meta_file = "#{draft_file}.meta.json"
334
+
335
+ FileUtils.mkdir_p(File.dirname(draft_file))
336
+
337
+ script_agent_key = job[:agent]&.downcase&.gsub(/[^a-z0-9-]/, "-")
338
+ meta = {
339
+ channel_id: job[:discord_channel_id],
340
+ agent_key: script_agent_key,
341
+ agent_name: job[:agent] || "Script",
342
+ cron_job_id: job[:id],
343
+ forum_title: job[:forum_title],
344
+ forum_reply_to_latest: job[:forum_reply_to_latest],
345
+ created_at: Time.now.iso8601
346
+ }
347
+ File.write(meta_file, JSON.pretty_generate(meta))
348
+ draft_file
349
+ end
350
+
351
+ # Read script output and write to draft file or log.
352
+ def deliver_script_output(job, log_file, draft_file)
353
+ return unless File.exist?(log_file)
354
+
355
+ output = File.read(log_file).strip
356
+
357
+ if job[:discord_channel_id] && draft_file && !output.empty?
358
+ File.write(draft_file, output)
359
+ LOG.info "[Cron] Script output written to #{draft_file} (#{output.length} chars)"
360
+ elsif !output.empty?
361
+ LOG.info "[Cron] Script output: #{output[0..200]}..."
362
+ else
363
+ LOG.warn "[Cron] Script produced no output"
364
+ end
365
+ end
366
+
367
+ # Build the prompt content and meta files for a cron job
368
+ def build_cron_prompt(job, project)
369
+ prompt = job[:prompt]
370
+ agent_name = job[:agent]
371
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
372
+
373
+ if job[:discord_channel_id]
374
+ draft_file = File.join(DISCORD_DRAFT_DIR, "cron-#{timestamp}-#{agent_name}-#{job[:id]}.md")
375
+ meta_file = "#{draft_file}.meta.json"
376
+ FileUtils.mkdir_p(File.dirname(draft_file))
377
+
378
+ agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
379
+ meta = {
380
+ channel_id: job[:discord_channel_id],
381
+ agent_key: agent_key,
382
+ agent_name: agent_name,
383
+ cron_job_id: job[:id],
384
+ forum_title: job[:forum_title],
385
+ forum_reply_to_latest: job[:forum_reply_to_latest],
386
+ created_at: Time.now.iso8601
387
+ }
388
+ File.write(meta_file, JSON.pretty_generate(meta))
389
+
390
+ full_prompt = <<~PROMPT
391
+ ## Scheduled Task (Discord Posting)
392
+ This is a scheduled cron job that will post to Discord channel #{job[:discord_channel_id]}.
393
+
394
+ You were asked to: "#{prompt}"
395
+
396
+ Project: #{job[:project]}
397
+ Source directory: #{project["repo_path"]}
398
+
399
+ **IMPORTANT: Write your response to #{draft_file}. Do NOT reply via stdout.**
400
+ Your response will be automatically posted to Discord.
401
+
402
+ #{prompt}
403
+ PROMPT
404
+
405
+ { response_file: draft_file, meta_file: meta_file, full_prompt: full_prompt }
406
+ else
407
+ response_file = File.join(ZILLACORE_DIR, "tmp", "cron", "cron-#{job[:id]}-#{Time.now.to_i}.md")
408
+ FileUtils.mkdir_p(File.dirname(response_file))
409
+
410
+ full_prompt = <<~PROMPT
411
+ ## Scheduled Task
412
+ This is a scheduled cron job. You were asked to: "#{prompt}"
413
+
414
+ Project: #{job[:project]}
415
+ Source directory: #{project["repo_path"]}
416
+
417
+ Write your response to: #{response_file}
418
+
419
+ #{prompt}
420
+ PROMPT
421
+
422
+ { response_file: response_file, meta_file: nil, full_prompt: full_prompt }
423
+ end
424
+ end
425
+
426
+ # Handle post-execution: extract response from log, update job state
427
+ def handle_cron_completion(job, project, agent_name, agent_config_name, log_file, response_file, meta_file)
428
+ cron_exit_status = $CHILD_STATUS.exitstatus
429
+ LOG.info "[Cron] Job #{job[:id]} finished (exit: #{cron_exit_status})"
430
+
431
+ if cron_exit_status && cron_exit_status != 0 && job[:discord_channel_id]
432
+ bot_token = discord_bot_tokens[agent_config_name] || discord_bot_tokens.values.first
433
+ if bot_token
434
+ notify_agent_crash(
435
+ exit_status: cron_exit_status, log_file: log_file,
436
+ agent_name: agent_name, source: :discord,
437
+ source_context: { channel_id: job[:discord_channel_id], bot_token: bot_token },
438
+ project_config: project
439
+ )
440
+ end
441
+ end
442
+
443
+ extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file)
444
+
445
+ qmd_out, qmd_status = Open3.capture2e("qmd", "update")
446
+ if qmd_status.success?
447
+ LOG.info "[Brain] qmd update completed after cron job #{job[:id]}"
448
+ else
449
+ LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
450
+ end
451
+
452
+ brain_push(message: "#{agent_config_name}: cron-#{job[:id]}")
453
+ update_cron_job_state(job)
454
+
455
+ if File.exist?(response_file)
456
+ LOG.info "[Cron] Job #{job[:id]} completed. Response: #{File.read(response_file)[0..100]}..."
457
+ else
458
+ LOG.warn "[Cron] Job #{job[:id]} produced no response"
459
+ end
460
+ end
461
+
462
+ # Extract agent response from log if the response file wasn't written directly
463
+ def extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file)
464
+ return if File.exist?(response_file)
465
+ return unless File.exist?(log_file)
466
+
467
+ log_content = File.read(log_file)
468
+
469
+ if log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/)
470
+ LOG.error "[Cron] Auth failure detected for job #{job[:id]} — " \
471
+ "re-authenticate with: kiro-cli --agent #{agent_config_name} chat"
472
+ File.delete(meta_file) if meta_file && File.exist?(meta_file)
473
+ return
474
+ end
475
+
476
+ clean_output = log_content
477
+ .gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "")
478
+ .gsub(/\e\][^\a]*\a/, "")
479
+ .delete("\r")
480
+ .gsub(/^.*?(using tool:.*?)$/m, "")
481
+ .gsub(/^.*?✓.*?$/m, "")
482
+ .gsub(/^.*?▸.*?$/m, "")
483
+ .gsub(/^.*?Loading\.\.\..*?$/m, "")
484
+ .gsub(/^.*?Completed in.*?$/m, "")
485
+ .strip
486
+
487
+ return unless !clean_output.empty? && clean_output.length > 20
488
+
489
+ File.write(response_file, clean_output)
490
+ LOG.info "[Cron] Extracted response from log (#{clean_output.length} chars)"
491
+ end
492
+
493
+ # Update cron job state after execution (last_run, execution_count, auto-disable)
494
+ def update_cron_job_state(job)
495
+ CRON_JOBS_MUTEX.synchronize do
496
+ jobs = load_cron_jobs
497
+ job_data = jobs[job[:id].to_sym]
498
+ return unless job_data
499
+
500
+ job_data[:last_run] = Time.now.iso8601
501
+ job_data[:execution_count] = (job_data[:execution_count] || 0) + 1
502
+
503
+ if job[:parsed][:one_time]
504
+ job_data[:enabled] = false
505
+ CRON_JOBS[job[:id].to_sym][:enabled] = false
506
+ LOG.info "[Cron] Auto-disabled one-time job: #{job[:id]}"
507
+ elsif job[:repeat_count] && job_data[:execution_count] >= job[:repeat_count]
508
+ job_data[:enabled] = false
509
+ CRON_JOBS[job[:id].to_sym][:enabled] = false
510
+ LOG.info "[Cron] Auto-disabled job #{job[:id]} after #{job[:repeat_count]} executions"
511
+ end
512
+
513
+ save_cron_jobs(jobs)
514
+ CRON_JOBS[job[:id].to_sym][:last_run] = Time.now.iso8601
515
+ CRON_JOBS[job[:id].to_sym][:execution_count] = job_data[:execution_count]
516
+ end
517
+ end
518
+
519
+ # Execute a cron job (dispatch agent)
520
+ def execute_cron_job(job)
521
+ return unless job[:enabled]
522
+
523
+ LOG.info "[Cron] Executing job #{job[:id]}: #{job[:prompt] || job[:script]}..."
524
+
525
+ project = PROJECTS[job[:project]]
526
+ unless project
527
+ LOG.error "[Cron] Project #{job[:project]} not found for job #{job[:id]}"
528
+ return
529
+ end
530
+
531
+ if job[:script]
532
+ execute_script_job(job, project)
533
+ return
534
+ end
535
+
536
+ agent_name = job[:agent]
537
+ agent_config_name = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
538
+ prompt_data = build_cron_prompt(job, project)
539
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
540
+
541
+ log_file = File.join(project["repo_path"], "tmp/agent-cron-#{job[:id]}-#{timestamp}.log")
542
+ FileUtils.mkdir_p(File.dirname(log_file))
543
+
544
+ prompt_file = write_cron_prompt_file(job, prompt_data[:full_prompt], timestamp)
545
+ cmd = build_cron_agent_cmd(job, project)
546
+
547
+ LOG.info "[Cron] Dispatching job #{job[:id]} with #{agent_name}, tail -f #{log_file}"
548
+
549
+ spawn_env = agent_env_for(agent_name)
550
+ LOG.info "[Cron] Injecting #{spawn_env.size} env var(s) for agent #{agent_name}" unless spawn_env.empty?
551
+
552
+ pid = spawn(spawn_env, *cmd,
553
+ chdir: project["repo_path"],
554
+ in: prompt_file,
555
+ out: [log_file, "w"],
556
+ err: %i[child out])
557
+
558
+ Thread.new do
559
+ Process.wait(pid)
560
+ handle_cron_completion(job, project, agent_name, agent_config_name, log_file, prompt_data[:response_file], prompt_data[:meta_file])
561
+ rescue StandardError => e
562
+ LOG.error "[Cron] Job #{job[:id]} failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
563
+ end
564
+ end
565
+
566
+ # Write cron prompt to a temp file, return path.
567
+ def write_cron_prompt_file(job, prompt_content, timestamp)
568
+ prompt_dir = File.join(ZILLACORE_DIR, "tmp")
569
+ FileUtils.mkdir_p(prompt_dir)
570
+ prompt_file = File.join(prompt_dir, "prompt-cron-#{job[:id]}-#{timestamp}.md")
571
+ File.write(prompt_file, prompt_content)
572
+ prompt_file
573
+ end
574
+
575
+ # Build the CLI command array for a cron agent invocation.
576
+ def build_cron_agent_cmd(job, project)
577
+ agent_config_name = job[:agent].downcase.gsub(/[^a-z0-9-]/, "-")
578
+ resolved = resolve_project_cli_config(project)
579
+ cmd = [resolved["agent_cli"]]
580
+ cmd.push("--agent", agent_config_name)
581
+ cmd.concat(resolved["agent_cli_args"].split)
582
+ add_trust_tools!(cmd, resolved["agent_cli_args"])
583
+ cmd.push(resolved["agent_model_flag"], job[:model]) if resolved["agent_model_flag"]&.length&.positive? && job[:model]
584
+ cmd.push(resolved["agent_effort_flag"], job[:effort]) if resolved["agent_effort_flag"]&.length&.positive? && job[:effort]
585
+ cmd
586
+ end
587
+
588
+ # Cron loop — runs every minute, checks all jobs
589
+ def cron_loop
590
+ loop do
591
+ now = Time.now
592
+
593
+ # Calculate sleep time to wake up at the start of the next minute
594
+ seconds_until_next_minute = 60 - now.sec
595
+ sleep seconds_until_next_minute
596
+
597
+ now = Time.now
598
+
599
+ CRON_JOBS_MUTEX.synchronize do
600
+ CRON_JOBS.each_value do |job|
601
+ next unless job[:enabled]
602
+ next unless cron_matches?(job[:parsed], now)
603
+
604
+ # Prevent duplicate runs within the same minute
605
+ if job[:last_run]
606
+ last_run_time = Time.parse(job[:last_run])
607
+ next if (now - last_run_time) < 60
608
+ end
609
+
610
+ execute_cron_job(job)
611
+ end
612
+ end
613
+ rescue StandardError => e
614
+ LOG.error "[Cron] Loop error: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
615
+ sleep 60
616
+ end
617
+ end
618
+
619
+ # Start cron background thread
620
+ def start_cron_thread
621
+ return if CRON_THREAD[:ref]&.alive?
622
+
623
+ reload_cron_jobs!
624
+
625
+ CRON_THREAD[:ref] = Thread.new do
626
+ LOG.info "[Cron] Starting cron thread..."
627
+ cron_loop
628
+ end
629
+ end