#!/usr/bin/env bash # # launch-upgrader.sh — spin up the cc-ci UPGRADER agent in tmux under remote-control. # # The Upgrader is a ONE-SHOT job agent (not a perpetual loop like the Builder/Adversary): it runs the # weekly recipe-upgrade sequence — the /upgrade-all skill in DEFAULT mode — to completion, then STOPS # and stays idle (it does NOT self-terminate) so the run + summary remain viewable/steerable at # claude.ai/code exactly like the Builder, instead of being buried in headless cron output. The next # weekly run starts a fresh session: `start` leaves an in-flight run alone but clears a finished/idle # (or wedged) session and starts clean. The weekly cron (Sat 03:00 UTC, once cc-ci is built — see # [[cc-ci-upgrade-all-cron]]) invokes `launch-upgrader.sh start`. # # Naming: tmux session AND remote-control name are both "cc-ci-upgrader" (matching # cc-ci-builder / cc-ci-adv / cc-ci-watchdog / cc-ci-orchestrator). # # Usage: # ./launch-upgrader.sh start # use-or-create: if a run is actively in flight leave it, # # else (no session / idle-stale) kill any stale + start fresh # ./launch-upgrader.sh fresh # always kill any existing + start a fresh run # ./launch-upgrader.sh status | attach | stop # # Env: # UPGRADER_ARGS="" passthrough args to /upgrade-all (e.g. "--dry-run", "ghost n8n"); default none # = full default fleet run. NEVER pass --with-tests here (the cron must not # auto-edit tests; that's the operator's per-recipe opt-in). set -euo pipefail SESSION="${UPGRADER_SESSION:-cc-ci-upgrader}" # tmux session name == remote-control name WORKDIR="${UPGRADER_DIR:-/srv/cc-ci}" # cwd: where .claude/skills/ + .testenv live # Backend selection — mirrors launch.sh. LOOP_BACKEND overrides for consistency. UPGRADER_BACKEND="${LOOP_BACKEND:-${UPGRADER_BACKEND:-claude}}" # "claude" or "opencode" # Model: LOOP_MODEL > UPGRADER_MODEL > backend default (sonnet for claude, provider/model for opencode). UPGRADER_MODEL="${LOOP_MODEL:-${UPGRADER_MODEL:-sonnet}}" CLAUDE_BIN="${CLAUDE_BIN:-claude}" CLAUDE_FLAGS="${CLAUDE_FLAGS:---dangerously-skip-permissions}" OPENCODE_BIN="${OPENCODE_BIN:-/home/loops/.local/bin/opencode}" OPENCODE_SERVER="${OPENCODE_SERVER:-http://127.0.0.1:4096}" REMOTE_CONTROL="${REMOTE_CONTROL:-1}" # 1 => --remote-control / opencode web LOG_DIR="${LOG_DIR:-/srv/cc-ci/.cc-ci-logs}" UPGRADER_ARGS="${UPGRADER_ARGS:-}" log() { printf '[upgrader %(%H:%M:%S)T] %s\n' -1 "$*"; } die() { log "ERROR: $*"; exit 1; } session_alive() { tmux has-session -t "$SESSION" 2>/dev/null; } # "actively working" = claude shows interrupt hint; opencode shows spinner/Running tool. session_busy() { tmux capture-pane -pt "$SESSION" 2>/dev/null | grep -qE 'esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool'; } preflight() { command -v tmux >/dev/null 2>&1 || die "missing dependency: tmux" case "$UPGRADER_BACKEND" in claude) command -v "$CLAUDE_BIN" >/dev/null 2>&1 || die "claude CLI not found (set CLAUDE_BIN)" ;; opencode) command -v "$OPENCODE_BIN" >/dev/null 2>&1 || die "opencode not found (set OPENCODE_BIN)" [[ -n "$OPENCODE_HOST" ]] || die "could not detect tailscale IP for OPENCODE_HOST" ;; *) die "unknown UPGRADER_BACKEND '$UPGRADER_BACKEND' — use 'claude' or 'opencode'" ;; esac [[ -d "$WORKDIR" ]] || die "workdir not found: $WORKDIR" [[ -d "$WORKDIR/.claude/skills/upgrade-all" ]] || die "upgrade-all skill not found under $WORKDIR/.claude/skills" mkdir -p "$LOG_DIR" } write_kickoff() { local kf="$LOG_DIR/.kickoff-$SESSION.txt" cat > "$kf" </dev/null || true; sleep 1 fi local kf kf="$(write_kickoff)" log "starting $SESSION (backend=$UPGRADER_BACKEND, model=$UPGRADER_MODEL, args='${UPGRADER_ARGS:-}')" case "$UPGRADER_BACKEND" in claude) local rc="" [[ "$REMOTE_CONTROL" == "1" ]] && rc="--remote-control '$SESSION'" tmux new-session -d -s "$SESSION" -c "$WORKDIR" \ "$CLAUDE_BIN $rc --model '$UPGRADER_MODEL' $CLAUDE_FLAGS \"\$(cat '$kf')\"" ;; opencode) tmux new-session -d -s "$SESSION" -c "$WORKDIR" \ "set -a; . /srv/cc-ci/.testenv; set +a; $OPENCODE_BIN --model '$UPGRADER_MODEL' run --attach '$OPENCODE_SERVER' --title '$SESSION' \"\$(cat '$kf')\"" log "$SESSION visible in web UI at http://oc.commoninternet.net (tailnet only)" ;; esac tmux pipe-pane -o -t "$SESSION" "cat >> '$LOG_DIR/$SESSION.log'" log "started. status: $0 status | attach: tmux attach -t $SESSION | log: $LOG_DIR/$SESSION.log" } case "${1:-start}" in start) start use-or-create ;; fresh) start fresh ;; stop) if session_alive; then log "killing $SESSION"; tmux kill-session -t "$SESSION" || true; else log "$SESSION not running"; fi ;; status) if session_alive; then log "$SESSION: RUNNING $(session_busy && echo '(busy)' || echo '(idle/finishing)')" ps -eo pid,etime,args | grep "[r]emote-control $SESSION" || true else log "$SESSION: stopped"; fi ;; attach) exec tmux attach -t "$SESSION" ;; *) cat <