From a87d42f491aacf2376007476c1e282af6c92dd87 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Sun, 31 May 2026 17:21:13 +0000 Subject: [PATCH] feat: opencode/tinfoil backend support in all launchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds LOOP_BACKEND=opencode|claude (+ LOOP_MODEL) to launch.sh and launch-upgrader.sh, enabling the loops/upgrader to run via opencode CLI against the tinfoil.sh API (deepseek-v4-pro etc.) instead of Claude. launch.sh: - LOOP_BACKEND (claude|opencode), LOOP_MODEL env vars - OPENCODE_BIN, OPENCODE_HOST (tailscale IP), OPENCODE_PORT (per-session) - start_agent: backend switch — claude path unchanged; opencode starts `opencode --hostname --port run ` so the web UI is bound to the tailscale interface (tailnet-only observability) - preflight: validates the right binary per backend - heal_session / heal_orchestrator: extend active-work detection to opencode spinner chars + "Running tool" - help: shows both backend configs launch-upgrader.sh: - UPGRADER_BACKEND / UPGRADER_MODEL (LOOP_BACKEND/LOOP_MODEL override) - start: same backend switch as launch.sh - OPENCODE_PORT=4098 (separate from loops 4096/4097) configuration.nix: note opencode binary location + re-install command. Tinfoil config: ~/.config/opencode/opencode.jsonc — provider "tinfoil" with baseURL=https://api.tinfoil.sh/v1, apiKey=env:TINFOIL_API_KEY (key + TINFOIL_MODEL + TINFOIL_BASE_URL stored in .testenv). opencode v1.15.13 installed at /home/loops/.local/bin/opencode. Usage: LOOP_BACKEND=opencode LOOP_MODEL=tinfoil/deepseek-v4-pro \ RESUME_PHASE=1 cc-ci-plan/launch.sh start Co-Authored-By: Claude Sonnet 4.6 --- cc-ci-plan/launch-upgrader.sh | 49 +++++++++--- cc-ci-plan/launch.sh | 79 +++++++++++++++---- .../configuration.nix | 5 +- 3 files changed, 107 insertions(+), 26 deletions(-) diff --git a/cc-ci-plan/launch-upgrader.sh b/cc-ci-plan/launch-upgrader.sh index b9e2f9c..4f629cc 100755 --- a/cc-ci-plan/launch-upgrader.sh +++ b/cc-ci-plan/launch-upgrader.sh @@ -27,22 +27,35 @@ 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}" -UPGRADER_MODEL="${UPGRADER_MODEL:-sonnet}" # model for the upgrader agent (sonnet = default) -REMOTE_CONTROL="${REMOTE_CONTROL:-1}" # 1 => --remote-control (viewable at claude.ai/code) +OPENCODE_BIN="${OPENCODE_BIN:-/home/loops/.local/bin/opencode}" +OPENCODE_HOST="${OPENCODE_HOST:-$(tailscale ip -4 2>/dev/null | head -1)}" +OPENCODE_PORT="${OPENCODE_PORT:-4098}" # upgrader gets its own port offset +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" = the TUI shows the interrupt hint (a turn in flight). Absent => idle/finished/wedged. -session_busy() { tmux capture-pane -pt "$SESSION" 2>/dev/null | grep -q 'esc to interrupt'; } +# "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" - command -v "$CLAUDE_BIN" >/dev/null 2>&1 || die "claude CLI not found (set CLAUDE_BIN)" + 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" @@ -88,12 +101,23 @@ start() { log "$SESSION exists but idle/stale (or fresh requested) — killing it first" tmux kill-session -t "$SESSION" 2>/dev/null || true; sleep 1 fi - local kf rc="" + local kf kf="$(write_kickoff)" - [[ "$REMOTE_CONTROL" == "1" ]] && rc="--remote-control '$SESSION'" - log "starting $SESSION (cwd=$WORKDIR, rc=$REMOTE_CONTROL, model=$UPGRADER_MODEL, args='${UPGRADER_ARGS:-}')" - tmux new-session -d -s "$SESSION" -c "$WORKDIR" \ - "$CLAUDE_BIN $rc --model '$UPGRADER_MODEL' $CLAUDE_FLAGS \"\$(cat '$kf')\"" + 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) + local web_url="http://${OPENCODE_HOST}:${OPENCODE_PORT}" + tmux new-session -d -s "$SESSION" -c "$WORKDIR" \ + "set -a; . /srv/cc-ci/.testenv; set +a; $OPENCODE_BIN --model '$UPGRADER_MODEL' --hostname '$OPENCODE_HOST' --port '$OPENCODE_PORT' run \"\$(cat '$kf')\"" + log "$SESSION web UI: $web_url (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" } @@ -118,7 +142,10 @@ cc-ci upgrader launcher — one-shot weekly recipe-upgrade job agent (remote-con $0 attach tmux attach to the session $0 stop kill the session -Env: SESSION=$SESSION WORKDIR=$WORKDIR REMOTE_CONTROL=$REMOTE_CONTROL UPGRADER_MODEL=$UPGRADER_MODEL UPGRADER_ARGS='${UPGRADER_ARGS:-}' +Env: UPGRADER_BACKEND=$UPGRADER_BACKEND UPGRADER_MODEL=$UPGRADER_MODEL UPGRADER_ARGS='${UPGRADER_ARGS:-}' + claude: CLAUDE_BIN=$CLAUDE_BIN REMOTE_CONTROL=$REMOTE_CONTROL + opencode: OPENCODE_BIN=$OPENCODE_BIN web=http://${OPENCODE_HOST}:${OPENCODE_PORT} (tailnet only) + (LOOP_BACKEND / LOOP_MODEL override UPGRADER_BACKEND / UPGRADER_MODEL for unified control) The agent runs /upgrade-all (DEFAULT mode) to completion, then STOPS and stays idle (viewable in the web UI). It does NOT self-terminate; the next weekly `start` clears the idle session and runs fresh. EOF diff --git a/cc-ci-plan/launch.sh b/cc-ci-plan/launch.sh index 7050d3e..70a747c 100755 --- a/cc-ci-plan/launch.sh +++ b/cc-ci-plan/launch.sh @@ -30,7 +30,22 @@ SELF="$(readlink -f "${BASH_SOURCE[0]}")" # ----- config ------------------------------------------------------------- PLAN_DIR="${PLAN_DIR:-/srv/cc-ci/cc-ci-plan}" + +# ----- backend selection ------------------------------------------------------ +# LOOP_BACKEND: "claude" (default) or "opencode" (tinfoil/opencode web, tailscale-only). +# LOOP_MODEL: model to pass to the backend. +# claude: e.g. "sonnet", "opus" (--model flag); empty = use CLI default. +# opencode: "provider/model" e.g. "tinfoil/deepseek-v4-pro". +LOOP_BACKEND="${LOOP_BACKEND:-claude}" +LOOP_MODEL="${LOOP_MODEL:-}" + CLAUDE_BIN="${CLAUDE_BIN:-claude}" +OPENCODE_BIN="${OPENCODE_BIN:-/home/loops/.local/bin/opencode}" +# Tailscale IP of this host — opencode web is bound here so only tailnet peers can reach it. +# Auto-detected from tailscale; override with OPENCODE_HOST if needed. +OPENCODE_HOST="${OPENCODE_HOST:-$(tailscale ip -4 2>/dev/null | head -1)}" +OPENCODE_PORT="${OPENCODE_PORT:-4096}" # fixed port so the URL is predictable + # --dangerously-skip-permissions cannot be passed as a FLAG when running as root (claude blocks it). # Use the env var form instead; detect root and switch automatically. if [ "$(id -u)" = "0" ]; then @@ -41,6 +56,7 @@ else fi # REMOTE_CONTROL=1 → interactive --remote-control sessions (viewable at claude.ai/code), required # for /loop. The box must be logged into the claude.ai account. =0 for plain interactive. +# For opencode backend this controls whether to start the opencode web server. REMOTE_CONTROL="${REMOTE_CONTROL:-1}" BUILDER_DIR="${BUILDER_DIR:-/srv/cc-ci/cc-ci}" # Builder's repo clone @@ -92,7 +108,12 @@ all_ids() { local p; for p in "${PHASES[@]}"; do printf '%s ' "$(echo "$p" preflight() { need tmux - command -v "$CLAUDE_BIN" >/dev/null 2>&1 || die "claude CLI not found (set CLAUDE_BIN)" + case "$LOOP_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); install from https://opencode.ai" + [[ -n "$OPENCODE_HOST" ]] || die "could not detect tailscale IP for OPENCODE_HOST" ;; + *) die "unknown LOOP_BACKEND '$LOOP_BACKEND' — use 'claude' or 'opencode'" ;; + esac local p plan for p in "${PHASES[@]}"; do plan="$(echo "$p" | cut -d'|' -f2)" @@ -105,9 +126,13 @@ preflight() { session_alive() { tmux has-session -t "$1" 2>/dev/null; } -# Build the per-session kickoff (phase preamble + base role prompt) and launch claude interactively. -# role ∈ {builder, adversary}. Passed as a POSITIONAL arg via inner $(cat ...) — never stdin -# (piping forces print mode and breaks /loop + remote-control). +# Build the per-session kickoff (phase preamble + base role prompt) and launch the agent. +# role ∈ {builder, adversary}. +# Backend "claude": prompt passed as positional arg via $(cat kf) — never stdin (piping breaks /loop). +# Backend "opencode": opencode serves a web UI on OPENCODE_HOST:OPENCODE_PORT (tailnet-only); +# each session gets a dedicated port offset (builder=+0, adversary=+1) so they don't collide. +# The kickoff prompt is passed via `opencode run ` in a detached tmux session; the web +# UI is accessible at http://OPENCODE_HOST:PORT for observation (like --remote-control). start_agent() { local role="$1" session="$2" workdir="$3" if session_alive "$session"; then log "$session already running — leaving it"; return 0; fi @@ -129,11 +154,32 @@ Wherever the standing rules below say "plan.md"/"STATUS.md"/"BACKLOG.md"/"REVIEW PREAMBLE cat "$PLAN_DIR/prompts/$role.md" } > "$kf" - log "starting $session (phase=$pid, plan=$plan, cwd=$workdir, rc=$REMOTE_CONTROL)" - local rc="" - [[ "$REMOTE_CONTROL" == "1" ]] && rc="--remote-control '$session'" - tmux new-session -d -s "$session" -c "$workdir" \ - "$CLAUDE_BIN $rc $CLAUDE_FLAGS \"\$(cat '$kf')\"" + + local model_flag="" + [[ -n "$LOOP_MODEL" ]] && model_flag="--model '$LOOP_MODEL'" + + case "$LOOP_BACKEND" in + claude) + local rc="" + [[ "$REMOTE_CONTROL" == "1" ]] && rc="--remote-control '$session'" + log "starting $session (backend=claude, phase=$pid, model=${LOOP_MODEL:-default}, cwd=$workdir)" + tmux new-session -d -s "$session" -c "$workdir" \ + "$CLAUDE_BIN $rc $model_flag $CLAUDE_FLAGS \"\$(cat '$kf')\"" + ;; + opencode) + # Assign a stable port per session so each has a unique web URL on the tailnet. + local port_offset=0 + [[ "$session" == "$ADV_SESSION" ]] && port_offset=1 + local port=$(( OPENCODE_PORT + port_offset )) + local web_url="http://${OPENCODE_HOST}:${port}" + log "starting $session (backend=opencode, phase=$pid, model=${LOOP_MODEL:-default}, web=$web_url)" + # opencode serve binds the web UI; opencode run sends the initial prompt to it. + # We start the server then send the kickoff message so the session is observable immediately. + tmux new-session -d -s "$session" -c "$workdir" \ + "set -a; . /srv/cc-ci/.testenv; set +a; $OPENCODE_BIN $model_flag --hostname '$OPENCODE_HOST' --port '$port' run \"\$(cat '$kf')\"" + log "$session web UI: $web_url (tailnet only)" + ;; + esac tmux pipe-pane -o -t "$session" "cat >> '$LOG_DIR/$session.log'" } @@ -189,7 +235,8 @@ heal_session() { start_agent "$role" "$s" "$dir"; return 0 fi pane="$(tmux capture-pane -pt "$s" 2>/dev/null | tail -25 || true)" - printf '%s\n' "$pane" | grep -q 'esc to interrupt' && return 0 # actively working — leave alone + # "esc to interrupt" = claude actively working; "running" or spinner chars = opencode actively working + printf '%s\n' "$pane" | grep -qE 'esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool' && return 0 if printf '%s\n' "$pane" | grep -qiE "$FATAL_RE"; then log "FATAL session-state error on $role ($s) — kill + restart fresh (re-orients from repo)" tmux kill-session -t "$s" 2>/dev/null || true @@ -281,7 +328,7 @@ heal_orchestrator() { if orchestrator_alive; then if tmux has-session -t "$ORCH_SESSION" 2>/dev/null; then local pane; pane="$(tmux capture-pane -pt "$ORCH_SESSION" 2>/dev/null | tail -25 || true)" - printf '%s\n' "$pane" | grep -q 'esc to interrupt' && return 0 # working — leave alone + printf '%s\n' "$pane" | grep -qE 'esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool' && return 0 if printf '%s\n' "$pane" | grep -qiE "$FATAL_RE"; then log "FATAL session-state error on orchestrator ($ORCH_SESSION) — kill + restart fresh" tmux kill-session -t "$ORCH_SESSION" 2>/dev/null || true @@ -452,9 +499,13 @@ cc-ci loop launcher (phase-aware) Phase sequence (auto-transition on per-phase ## DONE; STOP after the last = manual gate): $(all_ids) -Env: CLAUDE_BIN=$CLAUDE_BIN REMOTE_CONTROL=$REMOTE_CONTROL WATCH_INTERVAL=${WATCH_INTERVAL}s SIGNAL_INTERVAL=${SIGNAL_INTERVAL}s - PHASES_SPEC='$PHASES_SPEC' - RESUME_PHASE=1 to keep the current phase index instead of resetting to 0. +Env: LOOP_BACKEND=$LOOP_BACKEND LOOP_MODEL=${LOOP_MODEL:-} + claude: CLAUDE_BIN=$CLAUDE_BIN REMOTE_CONTROL=$REMOTE_CONTROL + opencode: OPENCODE_BIN=$OPENCODE_BIN OPENCODE_HOST=${OPENCODE_HOST:-} OPENCODE_PORT=$OPENCODE_PORT + (web UI: http://HOST:PORT per session, tailnet only; TINFOIL_API_KEY from .testenv) + WATCH_INTERVAL=${WATCH_INTERVAL}s SIGNAL_INTERVAL=${SIGNAL_INTERVAL}s + PHASES_SPEC='$PHASES_SPEC' + RESUME_PHASE=1 to keep the current phase index instead of resetting to 0. EOF ;; esac diff --git a/nix/hosts/cc-ci-orchestrator-hetzner/configuration.nix b/nix/hosts/cc-ci-orchestrator-hetzner/configuration.nix index de7bd88..9d60559 100644 --- a/nix/hosts/cc-ci-orchestrator-hetzner/configuration.nix +++ b/nix/hosts/cc-ci-orchestrator-hetzner/configuration.nix @@ -70,7 +70,10 @@ commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }]; }]; - # Ensure /home/loops/.local/bin (claude) is on the loops user PATH. + # Ensure /home/loops/.local/bin (claude + opencode) is on the loops user PATH. + # opencode binary is installed there manually (not yet in nixpkgs); re-install if missing: + # curl -sL https://github.com/anomalyco/opencode/releases/download/v1.15.13/opencode-linux-x64.tar.gz \ + # | tar -xz -C /home/loops/.local/bin opencode && chmod +x /home/loops/.local/bin/opencode environment.variables.PATH = lib.mkForce "/home/loops/.local/bin:/run/current-system/sw/bin:/run/wrappers/bin:/usr/bin:/bin";