#!/usr/bin/env python3 """ Multi-bot launcher for running all 5 Void agents simultaneously with aggregated logs. Usage: python run_bots.py [bsky.py arguments...] Example: python run_bots.py --synthesis-interval 0 --no-git Bots: void, herald, archivist, grunk, ezra """ import subprocess import threading import signal import sys import os from typing import List # ANSI color codes for each bot COLORS = { 'void': '\033[91m', # Red 'herald': '\033[94m', # Blue 'archivist': '\033[92m', # Green 'grunk': '\033[93m', # Yellow 'ezra': '\033[95m', # Magenta } RESET = '\033[0m' BOLD = '\033[1m' # Bot names and their config files BOTS = { 'void': 'configs/config.yaml', 'herald': 'configs/herald.yaml', 'archivist': 'configs/archivist.yaml', 'grunk': 'configs/grunk.yaml', 'ezra': 'configs/ezra.yaml', } # Track all running processes processes: List[subprocess.Popen] = [] shutdown_flag = threading.Event() shutdown_in_progress = threading.Lock() def stream_output(proc: subprocess.Popen, bot_name: str, stream_name: str): """Read from a process stream and print with colored prefix.""" stream = proc.stdout if stream_name == 'stdout' else proc.stderr color = COLORS.get(bot_name, '') prefix = f"{color}{BOLD}[{bot_name}]{RESET} " try: for line in iter(stream.readline, b''): if shutdown_flag.is_set(): break try: decoded = line.decode('utf-8').rstrip() if decoded: # Only print non-empty lines print(f"{prefix}{decoded}", flush=True) except UnicodeDecodeError: # Handle binary output gracefully print(f"{prefix}[binary output]", flush=True) except Exception as e: if not shutdown_flag.is_set(): print(f"{prefix}Error reading {stream_name}: {e}", flush=True) finally: stream.close() def shutdown_handler(signum, frame): """Handle shutdown signals gracefully.""" # Prevent re-entry if already shutting down if not shutdown_in_progress.acquire(blocking=False): # Already shutting down, force kill on second Ctrl+C print(f"\n{BOLD}⚡ Force killing all processes...{RESET}", flush=True) for proc in processes: if proc.poll() is None: try: proc.kill() except: pass sys.exit(1) try: print(f"\n{BOLD}🛑 Shutting down all bots...{RESET}", flush=True) shutdown_flag.set() # Send SIGTERM to all processes for proc in processes: if proc.poll() is None: # Still running try: proc.terminate() except Exception as e: print(f"Error terminating process: {e}", flush=True) # Wait for graceful shutdown (up to 3 seconds each) for i, proc in enumerate(processes): try: proc.wait(timeout=3) except subprocess.TimeoutExpired: print(f"Process {i} didn't exit gracefully, force killing...", flush=True) proc.kill() try: proc.wait(timeout=1) except: pass print(f"{BOLD}✅ All bots stopped{RESET}", flush=True) finally: sys.exit(0) def main(): """Launch all bots with aggregated output.""" # Set up signal handlers signal.signal(signal.SIGINT, shutdown_handler) signal.signal(signal.SIGTERM, shutdown_handler) # Get command-line arguments to pass to each bot bot_args = sys.argv[1:] print(f"{BOLD}🚀 Starting {len(BOTS)} bots...{RESET}", flush=True) if bot_args: print(f"{BOLD}Arguments: {' '.join(bot_args)}{RESET}", flush=True) print() # Start each bot threads = [] for bot_name, config_file in BOTS.items(): cmd = ['python', 'bsky.py', '--config', config_file] + bot_args try: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) processes.append(proc) # Start threads to read stdout and stderr stdout_thread = threading.Thread( target=stream_output, args=(proc, bot_name, 'stdout'), daemon=True ) stderr_thread = threading.Thread( target=stream_output, args=(proc, bot_name, 'stderr'), daemon=True ) stdout_thread.start() stderr_thread.start() threads.append(stdout_thread) threads.append(stderr_thread) color = COLORS.get(bot_name, '') print(f"{color}{BOLD}[{bot_name}]{RESET} Started (PID: {proc.pid})", flush=True) except Exception as e: print(f"Failed to start {bot_name}: {e}", flush=True) print() print(f"{BOLD}📡 Monitoring {len(processes)} bots. Press Ctrl+C to stop all.{RESET}", flush=True) print() # Wait for all processes to complete (or until interrupted) try: for proc in processes: proc.wait() except KeyboardInterrupt: # Signal handler will take care of cleanup pass # If we get here naturally (all processes exited), clean up if not shutdown_flag.is_set(): print(f"\n{BOLD}All bots have exited.{RESET}", flush=True) shutdown_flag.set() if __name__ == '__main__': main()