#!/usr/bin/env bash # # launch.sh — start and supervise the two cc-ci autonomous loops + a watchdog. # # Model (see plan.md §6 / §6.1): two INDEPENDENT Claude Code sessions — # • Builder (tmux session: cc-ci-builder) working clone /srv/cc-ci/cc-ci # • Adversary (tmux session: cc-ci-adv) working clone /srv/cc-ci/cc-ci-adv # coordinating only through the git repo on git.autonomic.zone. # # Each agent self-paces with a `/loop` (ScheduleWakeup) — that handles ITERATION. # This script's watchdog handles RESILIENCE: it restarts a session that has died # and stops everything once STATUS.md reports "## DONE". # # Usage: # ./launch.sh start # start both loops + watchdog (idempotent) # ./launch.sh watchdog # run only the supervision loop in the foreground # ./launch.sh status # show session + DONE state # ./launch.sh logs builder|adversary|watchdog # tail a session/log # ./launch.sh stop # stop both loops + watchdog # # Configure via env vars (defaults below). At minimum set CC_CI_REPO once the # Builder has created the repo, so the watchdog can detect DONE. set -euo pipefail # ----- config ------------------------------------------------------------- PLAN_DIR="${PLAN_DIR:-/srv/cc-ci/cc-ci-plan}" CLAUDE_BIN="${CLAUDE_BIN:-claude}" # Flags for unattended operation in a sandbox. Override if your setup differs. CLAUDE_FLAGS="${CLAUDE_FLAGS:---dangerously-skip-permissions}" # REMOTE_CONTROL=1 launches each agent as an INTERACTIVE session with --remote-control, # viewable/steerable at claude.ai/code (and the Claude mobile app). This is required for # /loop + ScheduleWakeup to work at all (they are interactive-only — a piped/print-mode # session cannot self-pace). Set REMOTE_CONTROL=0 for a plain interactive session with no # remote surface. The box must be logged into the claude.ai account (run `claude` once to # check `claude auth status`). Each agent gets its own RC session named after its tmux session. REMOTE_CONTROL="${REMOTE_CONTROL:-1}" BUILDER_DIR="${BUILDER_DIR:-/srv/cc-ci/cc-ci}" # Builder's repo clone (it creates this) ADV_DIR="${ADV_DIR:-/srv/cc-ci/cc-ci-adv}" # Adversary's repo clone WATCH_DIR="${WATCH_DIR:-/srv/cc-ci/.cc-ci-watch}" # tiny clone the watchdog reads STATUS.md from LOG_DIR="${LOG_DIR:-/srv/cc-ci/.cc-ci-logs}" CC_CI_REPO="${CC_CI_REPO:-https://git.autonomic.zone/recipe-maintainers/cc-ci.git}" # CI project repo (DONE detection); harmless until the Builder creates it CC_CI_BRANCH="${CC_CI_BRANCH:-main}" WATCH_INTERVAL="${WATCH_INTERVAL:-300}" # seconds between HEAVY checks (restart dead loops, DONE) SIGNAL_INTERVAL="${SIGNAL_INTERVAL:-30}" # seconds between HANDOFF checks (ping the waiting loop) BUILDER_SESSION="cc-ci-builder" ADV_SESSION="cc-ci-adv" WATCHDOG_SESSION="cc-ci-watchdog" # -------------------------------------------------------------------------- log() { printf '[launch %(%H:%M:%S)T] %s\n' -1 "$*"; } die() { log "ERROR: $*"; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; } preflight() { need tmux command -v "$CLAUDE_BIN" >/dev/null 2>&1 || die "claude CLI not found (set CLAUDE_BIN)" [[ -f "$PLAN_DIR/prompts/builder.md" ]] || die "missing $PLAN_DIR/prompts/builder.md" [[ -f "$PLAN_DIR/prompts/adversary.md" ]] || die "missing $PLAN_DIR/prompts/adversary.md" mkdir -p "$LOG_DIR" } session_alive() { tmux has-session -t "$1" 2>/dev/null; } # Start one agent loop in its own tmux session, cd'd into its working dir, with # the kickoff prompt passed to claude as a positional argument (see below for why # not stdin). start_agent() { local session="$1" workdir="$2" prompt_file="$3" if session_alive "$session"; then log "$session already running — leaving it" return 0 fi mkdir -p "$workdir" log "starting $session (cwd=$workdir, remote_control=$REMOTE_CONTROL)" # tmux gives claude a real PTY, so we run claude INTERACTIVELY (required for /loop + # ScheduleWakeup). The kickoff prompt is passed as a POSITIONAL argument via an inner # `$(cat ...)` — NOT piped on stdin, because piping forces print/headless mode which # breaks both interactivity and --remote-control. The `\$(...)` defers to the inner shell # so the whole multi-line prompt arrives as a single argument. local rc="" [[ "$REMOTE_CONTROL" == "1" ]] && rc="--remote-control '$session'" tmux new-session -d -s "$session" -c "$workdir" \ "$CLAUDE_BIN $rc $CLAUDE_FLAGS \"\$(cat '$prompt_file')\"" # Log the pane WITHOUT redirecting claude's stdout: a `>>log` redirect makes stdout a # non-tty and drops claude out of interactive/remote-control mode. pipe-pane mirrors the # live pane to the log file while claude keeps the PTY tmux gave it. tmux pipe-pane -o -t "$session" "cat >> '$LOG_DIR/$session.log'" } start_loops() { start_agent "$BUILDER_SESSION" "$BUILDER_DIR" "$PLAN_DIR/prompts/builder.md" start_agent "$ADV_SESSION" "$ADV_DIR" "$PLAN_DIR/prompts/adversary.md" } # Returns 0 (true) if the repo's STATUS.md contains a "## DONE" heading. is_done() { [[ -n "$CC_CI_REPO" ]] || return 1 if [[ ! -d "$WATCH_DIR/.git" ]]; then git clone --depth 1 --branch "$CC_CI_BRANCH" "$CC_CI_REPO" "$WATCH_DIR" >/dev/null 2>&1 || return 1 fi git -C "$WATCH_DIR" fetch --depth 1 origin "$CC_CI_BRANCH" >/dev/null 2>&1 || return 1 git -C "$WATCH_DIR" reset --hard "origin/$CC_CI_BRANCH" >/dev/null 2>&1 || return 1 grep -qE '^##[[:space:]]+DONE' "$WATCH_DIR/STATUS.md" 2>/dev/null } # Wake a loop by typing a one-line message into its tmux session (queues if mid-turn). ping_session() { local s="$1" msg="$2" session_alive "$s" || return 0 tmux send-keys -t "$s" -l -- "$msg" 2>/dev/null && { sleep 0.3; tmux send-keys -t "$s" Enter 2>/dev/null; } } # Edge-triggered handoff signalling: the moment one loop produces the artifact the other is # waiting on, ping the waiting loop so it wakes immediately instead of idling out its sleep. # Reads the loops' local working clones (same host) for the fastest signal; the pinged loop # still pulls the real state on wake. Edge-triggered (hash compare) so it pings once per change. _wd_last_gate=""; _wd_last_review="" handoff_check() { local sf="$BUILDER_DIR/STATUS.md" rf="$ADV_DIR/REVIEW.md" cur # Builder -> Adversary: a milestone gate is CLAIMED and awaiting verification. if [[ -f "$sf" ]]; then cur="$(grep -iE 'Gate:.*CLAIMED' "$sf" 2>/dev/null | sort -u | md5sum | awk '{print $1}')" if grep -qiE 'Gate:.*CLAIMED' "$sf" 2>/dev/null && [[ "$cur" != "$_wd_last_gate" ]]; then log "handoff: Builder CLAIMED a gate -> pinging Adversary" ping_session "$ADV_SESSION" "watchdog ping: the Builder has CLAIMED a milestone gate in STATUS.md and is awaiting your verification. Pull and verify it now — don't idle." fi _wd_last_gate="$cur" fi # Adversary -> Builder: REVIEW.md changed (a verdict/PASS/FAIL or a new finding). if [[ -f "$rf" ]]; then cur="$(md5sum "$rf" 2>/dev/null | awk '{print $1}')" if [[ -n "$cur" && "$cur" != "$_wd_last_review" ]]; then [[ -n "$_wd_last_review" ]] && { log "handoff: REVIEW.md changed -> pinging Builder" ping_session "$BUILDER_SESSION" "watchdog ping: the Adversary updated REVIEW.md (a verdict or finding). Pull and act now — if it PASSes your gate, proceed; if it's a finding, address it. Don't idle." } _wd_last_review="$cur" fi fi } watchdog_loop() { log "watchdog up (signal=${SIGNAL_INTERVAL}s, heavy=${WATCH_INTERVAL}s, repo=${CC_CI_REPO:-})" local elapsed="$WATCH_INTERVAL" # run a heavy check on the first tick too while true; do # Fast path every tick: ping a loop the moment its counterpart hands off. handoff_check # Heavy path every WATCH_INTERVAL: DONE detection + restart dead loops. if (( elapsed >= WATCH_INTERVAL )); then elapsed=0 if is_done; then log "STATUS.md reports ## DONE — stopping loops." stop_loops log "watchdog exiting (project complete)." exit 0 fi if ! session_alive "$BUILDER_SESSION"; then log "builder session gone — restarting" start_agent "$BUILDER_SESSION" "$BUILDER_DIR" "$PLAN_DIR/prompts/builder.md" fi if ! session_alive "$ADV_SESSION"; then log "adversary session gone — restarting" start_agent "$ADV_SESSION" "$ADV_DIR" "$PLAN_DIR/prompts/adversary.md" fi fi sleep "$SIGNAL_INTERVAL" elapsed=$(( elapsed + SIGNAL_INTERVAL )) done } start_watchdog() { if session_alive "$WATCHDOG_SESSION"; then log "watchdog already running" return 0 fi log "starting watchdog" tmux new-session -d -s "$WATCHDOG_SESSION" -c "$PLAN_DIR" \ "exec >>'$LOG_DIR/watchdog.log' 2>&1; '$0' watchdog" } stop_loops() { for s in "$BUILDER_SESSION" "$ADV_SESSION"; do if session_alive "$s"; then log "killing $s"; tmux kill-session -t "$s" || true; fi done } cmd_status() { for s in "$BUILDER_SESSION" "$ADV_SESSION" "$WATCHDOG_SESSION"; do if session_alive "$s"; then echo " $s: RUNNING"; else echo " $s: stopped"; fi done if [[ -n "$CC_CI_REPO" ]]; then if is_done; then echo " project: ## DONE"; else echo " project: in progress"; fi else echo " project: (CC_CI_REPO unset — DONE-detection disabled)" fi } case "${1:-}" in start) preflight start_loops start_watchdog log "started. inspect with: ./launch.sh status | attach: tmux attach -t $BUILDER_SESSION" ;; watchdog) preflight; watchdog_loop ;; status) cmd_status ;; logs) case "${2:-}" in builder) tail -f "$LOG_DIR/$BUILDER_SESSION.log" ;; adversary) tail -f "$LOG_DIR/$ADV_SESSION.log" ;; watchdog) tail -f "$LOG_DIR/watchdog.log" ;; *) die "usage: $0 logs builder|adversary|watchdog" ;; esac ;; stop) stop_loops if session_alive "$WATCHDOG_SESSION"; then log "killing $WATCHDOG_SESSION"; tmux kill-session -t "$WATCHDOG_SESSION" || true; fi log "stopped." ;; *) cat <} CLAUDE_BIN = $CLAUDE_BIN CLAUDE_FLAGS = $CLAUDE_FLAGS REMOTE_CONTROL = $REMOTE_CONTROL (1 = interactive --remote-control, viewable at claude.ai/code) BUILDER_DIR = $BUILDER_DIR ADV_DIR = $ADV_DIR WATCH_INTERVAL = ${WATCH_INTERVAL}s EOF ;; esac