#!/usr/bin/env python3 """ cc-ci orchestrator launcher — start/resume the orchestrator session in tmux. The orchestrator is the long-lived supervisory session: it watches the Builder/Adversary loops, reads their logs/STATUS, edits the plan/prompts, restarts stuck loops, and owns the VM-level fallback. It is SEPARATE from the loops that launch.py manages. Usage: launch-orchestrator.py start resume the persistent session (default) launch-orchestrator.py fresh start a NEW session (no --resume) launch-orchestrator.py stop kill the tmux session (conversation persists on disk) launch-orchestrator.py status show session state launch-orchestrator.py attach tmux attach to the session Env: LOOP_BACKEND claude (default) | opencode LOOP_MODEL model flag, e.g. "sonnet" or "tinfoil/deepseek-v4-pro" claude backend: CLAUDE_BIN claude REMOTE_CONTROL 1 (viewable at claude.ai/code) ORCH_SESSION_ID override the resume id (else read from $ID_FILE) ORCH_ID_FILE $LOG_DIR/.orchestrator-session-id ORCH_STARTUP_PROMPT startup nudge injected as the first turn after --resume opencode backend: OPENCODE_BIN /home/loops/.local/bin/opencode OPENCODE_SERVER http://127.0.0.1:4096 (no --resume equivalent; STARTUP_PROMPT is sent as the initial message; the session title in the web UI is the SESSION name) """ import os, sys, subprocess from datetime import datetime from pathlib import Path # ── config ──────────────────────────────────────────────────────────────────── SESSION = os.environ.get("ORCH_SESSION", "cc-ci-orchestrator-vm") WORKDIR = os.environ.get("ORCH_DIR", "/srv/cc-ci-orch") LOG_DIR = os.environ.get("LOG_DIR", "/srv/cc-ci/.cc-ci-logs") BACKEND = os.environ.get("LOOP_BACKEND", "claude") LOOP_MODEL = os.environ.get("LOOP_MODEL", "") # claude-specific CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude") CLAUDE_FLAGS = os.environ.get("CLAUDE_FLAGS", "--dangerously-skip-permissions") REMOTE_CONTROL = os.environ.get("REMOTE_CONTROL", "1") == "1" DEFAULT_ID = "c746050a-af11-409d-87ba-c05268e2e5d1" ID_FILE = os.environ.get("ORCH_ID_FILE", f"{LOG_DIR}/.orchestrator-session-id") STARTUP_PROMPT = os.environ.get("ORCH_STARTUP_PROMPT", ( "STARTUP (auto-launch): you are the cc-ci orchestrator, just (re)launched, likely after a " "reboot. Do your AGENTS.md On-startup routine NOW: read cc-ci-plan/REBOOTS.md and run " "cc-ci-plan/launch.py status, then send a proactive PushNotification that you are online " "with the current phase and reboot count, and confirm cc-ci-loops.service brought the loops " "+ watchdog back (relaunch with RESUME_PHASE=1 cc-ci-plan/launch.py start if not). " "Also read cc-ci-plan/JOURNAL.md for recent context before resuming supervision." )) # opencode-specific OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/loops/.local/bin/opencode") OPENCODE_SERVER = os.environ.get("OPENCODE_SERVER", "http://127.0.0.1:4096") # ── helpers ─────────────────────────────────────────────────────────────────── def log(msg): ts = datetime.now().strftime("%H:%M:%S") print(f"[orchestrator {ts}] {msg}", flush=True) def die(msg): log(f"ERROR: {msg}") sys.exit(1) def session_alive(): return subprocess.run( ["tmux", "has-session", "-t", SESSION], capture_output=True ).returncode == 0 def _ping(session, msg): """Type a message into the tmux session and submit it.""" import time subprocess.run(["tmux", "send-keys", "-t", session, "-l", "--", msg], capture_output=True) time.sleep(0.5) submit_key = "C-m" if BACKEND == "opencode" else "Enter" for _ in range(10): subprocess.run(["tmux", "send-keys", "-t", session, submit_key], capture_output=True) time.sleep(1) r = subprocess.run(["tmux", "capture-pane", "-pt", session], capture_output=True, text=True) if msg[:28] not in r.stdout: return def resume_id(): sid = os.environ.get("ORCH_SESSION_ID") if sid: return sid try: v = Path(ID_FILE).read_text().strip() return v or DEFAULT_ID except FileNotFoundError: return DEFAULT_ID # ── launch ──────────────────────────────────────────────────────────────────── def start(mode="resume"): import shutil if not shutil.which("tmux"): die("tmux not found") Path(LOG_DIR).mkdir(parents=True, exist_ok=True) if session_alive(): log(f"{SESSION} already running — leaving it (use 'stop' first to relaunch)") return model_flag = f"--model '{LOOP_MODEL}'" if LOOP_MODEL else "" if BACKEND == "claude": if not shutil.which(CLAUDE_BIN): die(f"claude CLI not found — set CLAUDE_BIN (currently: {CLAUDE_BIN})") if not Path(ID_FILE).exists(): Path(ID_FILE).write_text(DEFAULT_ID) rc = f"--remote-control '{SESSION}'" if REMOTE_CONTROL else "" resume = f"--resume '{resume_id()}'" if mode == "resume" else "" prompt = f"'{STARTUP_PROMPT}'" if STARTUP_PROMPT else "" cmd = f"{CLAUDE_BIN} {resume} {rc} {model_flag} {CLAUDE_FLAGS} {prompt}" detail = f"resume={resume_id()}" if mode == "resume" else "fresh" log(f"starting {SESSION} (backend=claude, {detail}, model={LOOP_MODEL or 'default'})") elif BACKEND == "opencode": if not Path(OPENCODE_BIN).exists(): die(f"opencode not found at {OPENCODE_BIN}") # Attach the orchestrator TUI to the shared opencode web server so it shows up in the # same project/session listing as browser-created sessions. cmd = ( f"set -a; . /srv/cc-ci/.testenv; set +a; " f"NO_COLOR=1 {OPENCODE_BIN} attach {OPENCODE_SERVER} --dir {WORKDIR}" ) log(f"starting {SESSION} (backend=opencode, model={LOOP_MODEL or 'default'})") log(f" visible at http://oc.commoninternet.net (tailnet only)") else: die(f"unknown LOOP_BACKEND '{BACKEND}' — use 'claude' or 'opencode'") subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-c", WORKDIR, cmd]) subprocess.run(["tmux", "pipe-pane", "-o", "-t", SESSION, f"cat >> '{LOG_DIR}/{SESSION}.log'"]) log(f"started. attach: tmux attach -t {SESSION}") # opencode: send the startup prompt once the TUI has connected (~8s). if BACKEND == "opencode": import time time.sleep(8) prompt = STARTUP_PROMPT or ( "You are the cc-ci orchestrator. Read cc-ci-plan/JOURNAL.md for full " "context on what's happened and what needs doing, then supervise the loops." ) # Send a pointer to the journal rather than the full prompt (avoids length limits). bootstrap = ( "You are the cc-ci orchestrator. Start by reading cc-ci-plan/JOURNAL.md " "(`cat cc-ci-plan/JOURNAL.md`) for full context, then check loop status " "(`cc-ci-plan/launch.sh status`) and resume supervising." ) _ping(SESSION, bootstrap) # ── main ────────────────────────────────────────────────────────────────────── def main(): cmd = sys.argv[1] if len(sys.argv) > 1 else "start" if cmd == "start": start("resume") elif cmd == "fresh": start("fresh") elif cmd == "stop": if session_alive(): log(f"killing {SESSION}") subprocess.run(["tmux", "kill-session", "-t", SESSION]) else: log(f"{SESSION} not running") elif cmd == "status": state = "RUNNING" if session_alive() else "stopped" log(f"{SESSION}: {state}") subprocess.run( f"ps -eo pid,etime,args | grep '[r]emote-control {SESSION}' || true", shell=True) if BACKEND == "claude": log(f"resume id: {resume_id()} (file: {ID_FILE})") log(f"backend: {BACKEND} model: {LOOP_MODEL or ''}") elif cmd == "attach": os.execvp("tmux", ["tmux", "attach", "-t", SESSION]) else: backend_note = ( "claude: --resume preserves conversation across reboots; viewable at claude.ai/code\n" " opencode: fresh session each launch (no --resume); viewable at http://oc.commoninternet.net" ) print(f"""cc-ci orchestrator launcher launch-orchestrator.py start resume the persistent session (default) launch-orchestrator.py fresh start a new session (no --resume) launch-orchestrator.py stop kill the tmux session launch-orchestrator.py status show session state launch-orchestrator.py attach tmux attach Backend: {BACKEND} (LOOP_BACKEND env var) Model: {LOOP_MODEL or ''} (LOOP_MODEL env var) Session: {SESSION} cwd={WORKDIR} {backend_note} """) if __name__ == "__main__": main()