feat: opencode/tinfoil backend support in all launchers

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 <ts-ip> --port <N> run <kickoff>` 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 <noreply@anthropic.com>
This commit is contained in:
autonomic-bot
2026-05-31 17:21:13 +00:00
co-authored by Claude Sonnet 4.6
parent 6910b197d0
commit a87d42f491
3 changed files with 107 additions and 26 deletions
+65 -14
View File
@@ -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 <message>` 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:-<default>}
claude: CLAUDE_BIN=$CLAUDE_BIN REMOTE_CONTROL=$REMOTE_CONTROL
opencode: OPENCODE_BIN=$OPENCODE_BIN OPENCODE_HOST=${OPENCODE_HOST:-<tailscale-ip>} 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