From 65f76a00ac3f3371b90ae7210a7edd38a7575d12 Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Mon, 14 Sep 2026 06:24:46 -0500 Subject: [PATCH] Bot and Troll launchers --- AGENTS.md | 15 ++ README.md | 45 +++++- launch_bots.py | 151 +++++++++++++++++++ launch_bots.sh | 4 + launch_trolls.py | 151 +++++++++++++++++++ launch_trolls.sh | 4 + launcher_common.py | 352 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 721 insertions(+), 1 deletion(-) create mode 100755 launch_bots.py create mode 100755 launch_bots.sh create mode 100755 launch_trolls.py create mode 100755 launch_trolls.sh create mode 100644 launcher_common.py diff --git a/AGENTS.md b/AGENTS.md index 10be7d9..5d9c8fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,11 @@ botWebWars/ ├── README.md # Human-facing overview and quickstart ├── Dockerfile # Multi-stage production container build (frontend + backend) ├── docker-compose.yml # Single-service container composition on port 8000 +├── launch_bots.py # Top-level batch launcher for botagent_ai instances +├── launch_bots.sh # Shell wrapper for launch_bots.py +├── launch_trolls.py # Top-level batch launcher for trollagent_ai instances +├── launch_trolls.sh # Shell wrapper for launch_trolls.py +├── launcher_common.py # Shared agent process orchestrator and log multiplexer │ ├── backend/ # FastAPI application & game engine │ ├── app/ @@ -278,6 +283,11 @@ The application is containerized into a single unified image via [Dockerfile](Do export OLLAMA_MODEL="gemma4:12b" python3 botagent_ai/bot.py -n MyAIBot -s 4 -H 10 -c "#8b5cf6" ``` +- **Batch Multi-Session Launch (Top-Level Script)**: + ```bash + # Launch 3 bots (named botagent_ai_gemma4_e4b_1, botagent_ai_gemma4_e4b_2, ...) + ./launch_bots.sh -n 3 -s 2 -H 2 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b" + ``` ### C. Vertex AI (Gemini) Bot: `botagent_gear/` - **Entry File**: `botagent_gear/bot.py` @@ -329,6 +339,11 @@ The application is containerized into a single unified image via [Dockerfile](Do export OLLAMA_MODEL="gemma4:12b" python3 trollagent_ai/bot.py -n CarnageTroll -s 4 -H 10 -c "#15803d" ``` +- **Batch Multi-Session Launch (Top-Level Script)**: + ```bash + # Launch 2 trolls (named trollagent_ai_gemma4_e4b_1, trollagent_ai_gemma4_e4b_2) + ./launch_trolls.sh -n 2 -s 2 -H 2 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b" + ``` ### F. Vertex AI (Gemini) Troll Bot: `trollagent_gear/` - **Entry File**: `trollagent_gear/bot.py` diff --git a/README.md b/README.md index e0b4f18..aaa7f4e 100644 --- a/README.md +++ b/README.md @@ -123,4 +123,47 @@ $ pip install -r requirements.txt $ python troll_agent.py --url "http://localhost:8000" --name "Fat Troll" --color "#10B981" -s 2 -H 2 ``` -BotAgents work the same way \ No newline at end of file +BotAgents work the same way + +--- + +## Multi-Agent Batch Launchers (Top-Level Scripts) + +Instead of opening multiple terminal tabs manually, you can launch pools of AI bots or trolls using the top-level launcher scripts. The scripts automatically name agents as `{folder}_{sanitized_model}_{number}` (e.g. `botagent_ai_gemma4_e4b_1`, `botagent_ai_gemma4_e4b_2`, etc.), auto-assign distinct colors, multiplex real-time colorized logs in one terminal, save individual log files under `logs/`, and gracefully clean up all sessions when interrupted (`Ctrl+C`). + +### Launch Multiple AI Bots (`botagent_ai`) +```bash +# Launch 3 AI bots with default parameters (gemma4:e4b, str 2, hp 2) +./launch_bots.sh -n 3 + +# Or with python directly, specifying custom parameters: +python3 launch_bots.py -n 4 -s 2 -H 2 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b" + +# Preview commands without executing +./launch_bots.py -n 3 --dry-run +``` + +### Launch Multiple AI Trolls (`trollagent_ai`) +```bash +# Launch 2 AI trolls with default parameters (gemma4:e4b, str 2, hp 2) +./launch_trolls.sh -n 2 + +# Or with python directly, specifying custom parameters: +python3 launch_trolls.py -n 2 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b" + +# Preview commands without executing +./launch_trolls.py -n 2 --dry-run +``` + +### CLI Options Supported: +- `-n`, `--count`, `--num`: Number of agents to launch (default: `1`). +- `-s`, `--strength`: Strength multiplier (default: `2`). +- `-H`, `--health`: Starting health points (default: `2`). +- `--ollama-url`: Ollama API base URL (default: `http://192.168.1.220:11434`). +- `-m`, `--model`, `--ollama-model`: Ollama model name (default: `gemma4:e4b`). +- `-u`, `--url`, `--server-url`: botWebWars backend URL (default: `http://localhost:8000`). +- `-c`, `--color`: Custom avatar hex color (default: auto-cycles vibrant palette). +- `--start-index`: Starting number index (e.g. `--start-index 4` for instances `_4`, `_5`, ...). +- `--dry-run`: Display all commands without launching. +- `--log-dir`: Directory for per-agent log files (default: `logs/`). +- `--no-logs`: Disable writing log files to disk. \ No newline at end of file diff --git a/launch_bots.py b/launch_bots.py new file mode 100755 index 0000000..79e75a3 --- /dev/null +++ b/launch_bots.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Launch a specified number of AI Bot Agent (botagent_ai) sessions for botWebWars. + +Example usage: + # Launch 3 bots with default parameters (gemma4:e4b, str 2, hp 2) + ./launch_bots.py -n 3 + + # Custom model, health, strength, and Ollama server + ./launch_bots.py -n 2 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b" + + # Preview commands without executing + ./launch_bots.py -n 3 --dry-run +""" + +import argparse +import os +import sys +from pathlib import Path + +# Ensure workspace root is in sys.path +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from launcher_common import ( + DEFAULT_BOT_COLORS, + run_agent_launcher, +) + +DEFAULT_SERVER_URL = os.getenv("BOT_SERVER_URL", "http://localhost:8000") +DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434") +DEFAULT_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:e4b") +DEFAULT_BOT_STRENGTH = int(os.getenv("BOT_STRENGTH", "2")) +DEFAULT_BOT_HEALTH = int(os.getenv("BOT_HEALTH", "2")) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Launch a specified number of AI Bot Agent (botagent_ai) sessions for botWebWars.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "-n", "--count", "--num", "--num-bots", + dest="count", + type=int, + default=1, + help="Number of bot agent instances to launch", + ) + parser.add_argument( + "-s", "--strength", + dest="strength", + type=int, + default=DEFAULT_BOT_STRENGTH, + help="Bot strength multiplier (1-10)", + ) + parser.add_argument( + "-H", "--health", + dest="health", + type=int, + default=DEFAULT_BOT_HEALTH, + help="Starting health points", + ) + parser.add_argument( + "--ollama-url", + dest="ollama_url", + default=DEFAULT_OLLAMA_URL, + help="Ollama API base URL", + ) + parser.add_argument( + "-m", "--model", "--ollama-model", + dest="model", + default=DEFAULT_OLLAMA_MODEL, + help="Ollama model identifier", + ) + parser.add_argument( + "-u", "--url", "--server-url", + dest="server_url", + default=DEFAULT_SERVER_URL, + help="botWebWars server URL", + ) + parser.add_argument( + "-c", "--color", + dest="color", + default=None, + help="Custom hex color code for avatar (default: auto-cycles vibrant palette)", + ) + parser.add_argument( + "--start-index", + dest="start_index", + type=int, + default=1, + help="Starting index number for bot naming (e.g. start at 4 for botagent_ai_model_4)", + ) + parser.add_argument( + "--prefix", + dest="prefix", + default="botagent_ai", + help="Custom naming prefix before model and index", + ) + parser.add_argument( + "--piece-type", + dest="piece_type", + choices=["knight", "warrior"], + default=None, + help="Optional board piece class", + ) + parser.add_argument( + "--log-dir", + dest="log_dir", + default="logs", + help="Directory to store per-bot log files", + ) + parser.add_argument( + "--no-logs", + dest="no_logs", + action="store_true", + help="Disable writing logs to files", + ) + parser.add_argument( + "--python", + dest="python", + default=None, + help="Custom path to python interpreter binary", + ) + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="Print the launch plan and commands without executing them", + ) + return parser.parse_args() + + +def extra_args_builder(args): + extra = [] + if args.piece_type: + extra.extend(["--piece-type", args.piece_type]) + return extra + + +def main(): + args = parse_args() + run_agent_launcher( + agent_type="Bot", + folder="botagent_ai", + palette=DEFAULT_BOT_COLORS, + args=args, + extra_args_builder=extra_args_builder, + ) + + +if __name__ == "__main__": + main() diff --git a/launch_bots.sh b/launch_bots.sh new file mode 100755 index 0000000..6e5d91e --- /dev/null +++ b/launch_bots.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Shell wrapper for launch_bots.py +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "${SCRIPT_DIR}/launch_bots.py" "$@" diff --git a/launch_trolls.py b/launch_trolls.py new file mode 100755 index 0000000..9ddfb8b --- /dev/null +++ b/launch_trolls.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Launch a specified number of AI Troll Agent (trollagent_ai) sessions for botWebWars. + +Example usage: + # Launch 2 trolls with default parameters (gemma4:e4b, str 2, hp 2) + ./launch_trolls.py -n 2 + + # Custom strength, health, model, and Ollama server + ./launch_trolls.py -n 3 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b" + + # Preview commands without executing + ./launch_trolls.py -n 2 --dry-run +""" + +import argparse +import os +import sys +from pathlib import Path + +# Ensure workspace root is in sys.path +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from launcher_common import ( + DEFAULT_TROLL_COLORS, + run_agent_launcher, +) + +DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000")) +DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434") +DEFAULT_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:e4b") +DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "2.0")) +DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "2.0")) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Launch a specified number of AI Troll Agent (trollagent_ai) sessions for botWebWars.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "-n", "--count", "--num", "--num-trolls", + dest="count", + type=int, + default=1, + help="Number of troll agent instances to launch", + ) + parser.add_argument( + "-s", "--strength", + dest="strength", + type=float, + default=DEFAULT_TROLL_STRENGTH, + help="Troll strength multiplier (1-10)", + ) + parser.add_argument( + "-H", "--health", + dest="health", + type=float, + default=DEFAULT_TROLL_HEALTH, + help="Starting health points", + ) + parser.add_argument( + "--ollama-url", + dest="ollama_url", + default=DEFAULT_OLLAMA_URL, + help="Ollama API base URL", + ) + parser.add_argument( + "-m", "--model", "--ollama-model", + dest="model", + default=DEFAULT_OLLAMA_MODEL, + help="Ollama model identifier", + ) + parser.add_argument( + "-u", "--url", "--server-url", + dest="server_url", + default=DEFAULT_SERVER_URL, + help="botWebWars server URL", + ) + parser.add_argument( + "-c", "--color", + dest="color", + default=None, + help="Custom hex color code for avatar (default: auto-cycles troll green palette)", + ) + parser.add_argument( + "--start-index", + dest="start_index", + type=int, + default=1, + help="Starting index number for troll naming (e.g. start at 3 for trollagent_ai_model_3)", + ) + parser.add_argument( + "--prefix", + dest="prefix", + default="trollagent_ai", + help="Custom naming prefix before model and index", + ) + parser.add_argument( + "--loop-delay", + dest="loop_delay", + type=float, + default=1.0, + help="Turn polling interval in seconds", + ) + parser.add_argument( + "--log-dir", + dest="log_dir", + default="logs", + help="Directory to store per-troll log files", + ) + parser.add_argument( + "--no-logs", + dest="no_logs", + action="store_true", + help="Disable writing logs to files", + ) + parser.add_argument( + "--python", + dest="python", + default=None, + help="Custom path to python interpreter binary", + ) + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="Print the launch plan and commands without executing them", + ) + return parser.parse_args() + + +def extra_args_builder(args): + extra = [] + if args.loop_delay: + extra.extend(["--loop-delay", str(args.loop_delay)]) + return extra + + +def main(): + args = parse_args() + run_agent_launcher( + agent_type="Troll", + folder="trollagent_ai", + palette=DEFAULT_TROLL_COLORS, + args=args, + extra_args_builder=extra_args_builder, + ) + + +if __name__ == "__main__": + main() diff --git a/launch_trolls.sh b/launch_trolls.sh new file mode 100755 index 0000000..8c9192f --- /dev/null +++ b/launch_trolls.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Shell wrapper for launch_trolls.py +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "${SCRIPT_DIR}/launch_trolls.py" "$@" diff --git a/launcher_common.py b/launcher_common.py new file mode 100644 index 0000000..638ad82 --- /dev/null +++ b/launcher_common.py @@ -0,0 +1,352 @@ +"""Common process management and launcher utilities for botWebWars agents.""" + +import argparse +import os +import re +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import List, Optional, Tuple + +# ANSI color codes for pretty terminal logging +RESET = "\033[0m" +BOLD = "\033[1m" +GREEN = "\033[1;32m" +YELLOW = "\033[1;33m" +RED = "\033[1;31m" +CYAN = "\033[1;36m" +MAGENTA = "\033[1;35m" +BLUE = "\033[1;34m" + +CONSOLE_COLORS = [ + "\033[1;34m", # Blue + "\033[1;35m", # Magenta + "\033[1;36m", # Cyan + "\033[1;32m", # Green + "\033[1;33m", # Yellow + "\033[1;31m", # Red + "\033[1;94m", # Light Blue + "\033[1;95m", # Light Magenta + "\033[1;96m", # Light Cyan + "\033[1;92m", # Light Green +] + +DEFAULT_BOT_COLORS = [ + "#3b82f6", # Blue + "#8b5cf6", # Purple + "#ec4899", # Pink + "#06b6d4", # Cyan + "#f59e0b", # Amber + "#10b981", # Emerald + "#6366f1", # Indigo + "#f43f5e", # Rose + "#14b8a6", # Teal + "#e11d48", # Crimson +] + +DEFAULT_TROLL_COLORS = [ + "#15803d", # Forest Green + "#166534", # Dark Green + "#047857", # Emerald / Swamp + "#4d7c0f", # Olive Lime + "#3f6212", # Deep Olive + "#b45309", # Dark Amber / Brown + "#7c2d12", # Rust / Bloodwood + "#581c87", # Dark Purple + "#0f766e", # Deep Teal + "#1e293b", # Slate Dark +] + + +def sanitize_model_name(model: str) -> str: + """Sanitizes model name by replacing non-alphanumeric characters with underscores. + + Example: 'gemma4:e4b' -> 'gemma4_e4b', 'llama3.2:1b' -> 'llama3_2_1b' + """ + cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", model).strip("_") + return cleaned or "model" + + +def make_agent_name(folder: str, model: str, index: int, prefix: Optional[str] = None) -> str: + """Generates the standardized agent name in the format: {folder}_{model}_{index}. + + Example: 'botagent_ai_gemma4_e4b_3' + """ + base_prefix = prefix.strip("_") if prefix else folder + clean_model = sanitize_model_name(model) + return f"{base_prefix}_{clean_model}_{index}" + + +def find_python_executable(agent_dir: Path, custom_python: Optional[str] = None) -> str: + """Locates the appropriate Python binary: + 1. Custom explicit python path if provided. + 2. Active virtual environment if invoked inside one. + 3. Agent directory local virtual environment (e.g. {agent_dir}/venv/bin/python). + 4. sys.executable or system python3. + """ + if custom_python: + return custom_python + + # If the user explicitly activated an environment outside + if getattr(sys, "base_prefix", None) != sys.prefix: + return sys.executable + + # Check local venv inside the agent's folder + local_venv = agent_dir / "venv" / "bin" / "python" + if local_venv.is_file() and os.access(local_venv, os.X_OK): + return str(local_venv) + + # Fallback to current sys.executable + return sys.executable + + +def stream_output( + agent_name: str, + color_code: str, + proc: subprocess.Popen, + log_file: Optional[Path], +) -> None: + """Streams child process stdout/stderr line-by-line to console and log file.""" + log_fp = None + if log_file: + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + log_fp = open(log_file, "a", encoding="utf-8") + except Exception as e: + print(f"{RED}⚠️ Failed to open log file {log_file}: {e}{RESET}", flush=True) + + last_lobby_time = 0.0 + try: + if proc.stdout: + for raw_line in iter(proc.stdout.readline, ""): + if not raw_line: + break + if log_fp: + log_fp.write(raw_line) + log_fp.flush() + + line = raw_line.rstrip("\r\n") + if not line: + continue + + # Throttle lobby polling notifications to avoid console flooding + if "[LOBBY]" in line: + now = time.time() + if now - last_lobby_time < 8.0: + continue + last_lobby_time = now + + print(f"{color_code}[{agent_name}]{RESET} {line}", flush=True) + except Exception: + pass + finally: + if log_fp: + try: + log_fp.close() + except Exception: + pass + + +def stop_all_processes(procs: List[Tuple[str, subprocess.Popen]]) -> None: + """Gracefully terminates all child processes by sending SIGINT first.""" + if not procs: + return + + running_procs = [(name, p) for name, p in procs if p.poll() is None] + if not running_procs: + return + + print(f"\n{YELLOW}🛑 Stopping {len(running_procs)} agent session(s) gracefully...{RESET}", flush=True) + + # 1. Send SIGINT so bot.py catches KeyboardInterrupt and cleanly deregisters from the board + for name, proc in running_procs: + if proc.poll() is None: + try: + proc.send_signal(signal.SIGINT) + except (ProcessLookupError, OSError): + pass + + # 2. Give processes up to 4 seconds to deregister and exit cleanly + deadline = time.time() + 4.0 + for name, proc in running_procs: + remaining = max(0.1, deadline - time.time()) + try: + proc.wait(timeout=remaining) + except subprocess.TimeoutExpired: + pass + + # 3. Terminate/kill any processes that hung + for name, proc in running_procs: + if proc.poll() is None: + print(f"{RED}⚠️ Agent {name} did not exit in time; terminating...{RESET}", flush=True) + try: + proc.terminate() + proc.wait(timeout=1.0) + except Exception: + try: + proc.kill() + except Exception: + pass + + print(f"{GREEN}✅ All agent sessions stopped cleanly.{RESET}\n", flush=True) + + +def run_agent_launcher( + agent_type: str, + folder: str, + palette: List[str], + args: argparse.Namespace, + extra_args_builder=None, +) -> None: + """Generic orchestrator for launching bot and troll agent pools.""" + root_dir = Path(__file__).resolve().parent + agent_dir = root_dir / folder + + if not agent_dir.is_dir(): + print(f"{RED}❌ Error: Agent directory '{agent_dir}' not found.{RESET}", file=sys.stderr) + sys.exit(1) + + entry_file = agent_dir / "bot.py" + if not entry_file.is_file(): + print(f"{RED}❌ Error: Entrypoint '{entry_file}' not found.{RESET}", file=sys.stderr) + sys.exit(1) + + python_bin = find_python_executable(agent_dir, args.python) + count = max(1, args.count) + start_index = max(1, args.start_index) + prefix = getattr(args, "prefix", None) + + # Prepare list of commands and configurations + agent_configs = [] + for offset in range(count): + idx = start_index + offset + name = make_agent_name(folder=folder, model=args.model, index=idx, prefix=prefix) + color = args.color if args.color else palette[offset % len(palette)] + console_color = CONSOLE_COLORS[offset % len(CONSOLE_COLORS)] + + cmd = [ + python_bin, + "bot.py", + "--name", name, + "--color", color, + "-s", str(args.strength), + "-H", str(args.health), + "--ollama-url", args.ollama_url, + "--ollama-model", args.model, + "-u", args.server_url, + ] + + if extra_args_builder: + extra = extra_args_builder(args) + if extra: + cmd.extend(extra) + + log_file = None + if not args.no_logs and args.log_dir: + log_file = Path(args.log_dir).resolve() / f"{name}.log" + + agent_configs.append({ + "index": idx, + "name": name, + "color": color, + "console_color": console_color, + "cmd": cmd, + "log_file": log_file, + }) + + # Dry-run display mode + if args.dry_run: + print(f"\n{CYAN}{'=' * 68}{RESET}") + print(f"{BOLD}🔍 DRY RUN: {count} {agent_type}(s) [{folder}]{RESET}") + print(f"{CYAN}{'=' * 68}{RESET}") + print(f"Model: {BOLD}{args.model}{RESET}") + print(f"Ollama URL: {args.ollama_url}") + print(f"Server URL: {args.server_url}") + print(f"Strength: {args.strength} | Health: {args.health}") + print(f"Interpreter: {python_bin}") + print(f"Working Dir: {agent_dir}") + if not args.no_logs and args.log_dir: + print(f"Log Dir: {Path(args.log_dir).resolve()}") + print(f"{CYAN}{'-' * 68}{RESET}") + + for item in agent_configs: + cmd_str = " ".join(item["cmd"]) + print(f"[{item['index']}] Name: {BOLD}{item['name']}{RESET}") + print(f" Color: {item['color']}") + print(f" Cmd: {cmd_str}\n") + print(f"{CYAN}{'=' * 68}{RESET}\n") + return + + # Normal execution + print(f"\n{CYAN}{'=' * 68}{RESET}") + print(f"{BOLD}🚀 Launching {count} {agent_type}(s) [{folder}]{RESET}") + print(f"Model: {BOLD}{args.model}{RESET}") + print(f"Ollama URL: {args.ollama_url}") + print(f"Server URL: {args.server_url}") + print(f"Strength: {args.strength} | Health: {args.health}") + print(f"Interpreter: {python_bin}") + if not args.no_logs and args.log_dir: + print(f"Logs: {Path(args.log_dir).resolve()}/.log") + print(f"{CYAN}{'=' * 68}{RESET}") + + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + env["BOT_SERVER_URL"] = args.server_url + if "troll" in folder.lower(): + env["TROLL_SERVER_URL"] = args.server_url + + procs: List[Tuple[str, subprocess.Popen]] = [] + threads: List[threading.Thread] = [] + + interrupted = threading.Event() + + def handle_signal(signum, frame): + interrupted.set() + + # Register signal traps + old_sigint = signal.signal(signal.SIGINT, handle_signal) + old_sigterm = signal.signal(signal.SIGTERM, handle_signal) + + try: + for item in agent_configs: + print(f"✨ Spawning {item['console_color']}{item['name']}{RESET} ({item['color']})...") + proc = subprocess.Popen( + item["cmd"], + cwd=str(agent_dir), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + procs.append((item["name"], proc)) + + t = threading.Thread( + target=stream_output, + args=(item["name"], item["console_color"], proc, item["log_file"]), + daemon=True, + ) + t.start() + threads.append(t) + time.sleep(0.15) # Slight stagger for clean initial connections + + print(f"{CYAN}{'=' * 68}{RESET}") + print(f"{BOLD}💡 All {len(procs)} agent(s) spawned. Press Ctrl+C at any time to stop.{RESET}") + print(f"{CYAN}{'=' * 68}{RESET}\n") + + # Monitor loop + while not interrupted.is_set(): + # Check if all processes have exited naturally + if all(p.poll() is not None for _, p in procs): + break + interrupted.wait(timeout=0.5) + + except KeyboardInterrupt: + interrupted.set() + finally: + stop_all_processes(procs) + signal.signal(signal.SIGINT, old_sigint) + signal.signal(signal.SIGTERM, old_sigterm)