a digital person for bluesky

Add multi-bot launcher script with aggregated logs

Creates run_bots.py to launch all 5 bot configs simultaneously with
docker-compose-style aggregated output. Features colored prefixes for
each bot, graceful shutdown handling, and argument passthrough.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+154
+154
run_bots.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Multi-bot launcher for running all Void agents simultaneously with aggregated logs. 4 + Usage: python run_bots.py [bsky.py arguments...] 5 + Example: python run_bots.py --synthesis-interval 0 --no-git 6 + """ 7 + 8 + import subprocess 9 + import threading 10 + import signal 11 + import sys 12 + import os 13 + from typing import List 14 + 15 + # ANSI color codes for each bot 16 + COLORS = { 17 + 'herald': '\033[94m', # Blue 18 + 'archivist': '\033[92m', # Green 19 + 'grunk': '\033[93m', # Yellow 20 + 'ezra': '\033[95m', # Magenta 21 + 'blank': '\033[96m', # Cyan 22 + } 23 + RESET = '\033[0m' 24 + BOLD = '\033[1m' 25 + 26 + # List of bot configs to run 27 + BOTS = ['herald', 'archivist', 'grunk', 'ezra', 'blank'] 28 + 29 + # Track all running processes 30 + processes: List[subprocess.Popen] = [] 31 + shutdown_flag = threading.Event() 32 + 33 + 34 + def stream_output(proc: subprocess.Popen, bot_name: str, stream_name: str): 35 + """Read from a process stream and print with colored prefix.""" 36 + stream = proc.stdout if stream_name == 'stdout' else proc.stderr 37 + color = COLORS.get(bot_name, '') 38 + prefix = f"{color}{BOLD}[{bot_name}]{RESET} " 39 + 40 + try: 41 + for line in iter(stream.readline, b''): 42 + if shutdown_flag.is_set(): 43 + break 44 + try: 45 + decoded = line.decode('utf-8').rstrip() 46 + if decoded: # Only print non-empty lines 47 + print(f"{prefix}{decoded}", flush=True) 48 + except UnicodeDecodeError: 49 + # Handle binary output gracefully 50 + print(f"{prefix}[binary output]", flush=True) 51 + except Exception as e: 52 + if not shutdown_flag.is_set(): 53 + print(f"{prefix}Error reading {stream_name}: {e}", flush=True) 54 + finally: 55 + stream.close() 56 + 57 + 58 + def shutdown_handler(signum, frame): 59 + """Handle shutdown signals gracefully.""" 60 + print(f"\n{BOLD}🛑 Shutting down all bots...{RESET}", flush=True) 61 + shutdown_flag.set() 62 + 63 + # Send SIGTERM to all processes 64 + for proc in processes: 65 + if proc.poll() is None: # Still running 66 + try: 67 + proc.terminate() 68 + except Exception as e: 69 + print(f"Error terminating process: {e}", flush=True) 70 + 71 + # Wait for graceful shutdown (up to 10 seconds each) 72 + for i, proc in enumerate(processes): 73 + try: 74 + proc.wait(timeout=10) 75 + except subprocess.TimeoutExpired: 76 + print(f"Process {i} didn't exit gracefully, force killing...", flush=True) 77 + proc.kill() 78 + proc.wait() 79 + 80 + print(f"{BOLD}✅ All bots stopped{RESET}", flush=True) 81 + sys.exit(0) 82 + 83 + 84 + def main(): 85 + """Launch all bots with aggregated output.""" 86 + # Set up signal handlers 87 + signal.signal(signal.SIGINT, shutdown_handler) 88 + signal.signal(signal.SIGTERM, shutdown_handler) 89 + 90 + # Get command-line arguments to pass to each bot 91 + bot_args = sys.argv[1:] 92 + 93 + print(f"{BOLD}🚀 Starting {len(BOTS)} bots...{RESET}", flush=True) 94 + if bot_args: 95 + print(f"{BOLD}Arguments: {' '.join(bot_args)}{RESET}", flush=True) 96 + print() 97 + 98 + # Start each bot 99 + threads = [] 100 + for bot_name in BOTS: 101 + config_file = f"{bot_name}.yaml" 102 + cmd = ['python', 'bsky.py', '--config', config_file] + bot_args 103 + 104 + try: 105 + proc = subprocess.Popen( 106 + cmd, 107 + stdout=subprocess.PIPE, 108 + stderr=subprocess.PIPE, 109 + ) 110 + processes.append(proc) 111 + 112 + # Start threads to read stdout and stderr 113 + stdout_thread = threading.Thread( 114 + target=stream_output, 115 + args=(proc, bot_name, 'stdout'), 116 + daemon=True 117 + ) 118 + stderr_thread = threading.Thread( 119 + target=stream_output, 120 + args=(proc, bot_name, 'stderr'), 121 + daemon=True 122 + ) 123 + 124 + stdout_thread.start() 125 + stderr_thread.start() 126 + threads.append(stdout_thread) 127 + threads.append(stderr_thread) 128 + 129 + color = COLORS.get(bot_name, '') 130 + print(f"{color}{BOLD}[{bot_name}]{RESET} Started (PID: {proc.pid})", flush=True) 131 + 132 + except Exception as e: 133 + print(f"Failed to start {bot_name}: {e}", flush=True) 134 + 135 + print() 136 + print(f"{BOLD}📡 Monitoring {len(processes)} bots. Press Ctrl+C to stop all.{RESET}", flush=True) 137 + print() 138 + 139 + # Wait for all processes to complete (or until interrupted) 140 + try: 141 + for proc in processes: 142 + proc.wait() 143 + except KeyboardInterrupt: 144 + # Signal handler will take care of cleanup 145 + pass 146 + 147 + # If we get here naturally (all processes exited), clean up 148 + if not shutdown_flag.is_set(): 149 + print(f"\n{BOLD}All bots have exited.{RESET}", flush=True) 150 + shutdown_flag.set() 151 + 152 + 153 + if __name__ == '__main__': 154 + main()