#!/usr/bin/env bash # # opencode-builder.sh # # Launches an OpenCode server, creates a session with the auto-agents # agent, and continuously sends "continue" messages in an infinite loop. # # Signal handling: # Ctrl+C once — finishes the in-flight request, then stops the loop # Ctrl+C twice — (within 2 seconds) kills the request and exits immediately # set -uo pipefail # ── Configuration (override via environment) ────────────────────────────── PORT="${OPENCODE_PORT:-4096}" HOST="${OPENCODE_HOST:-127.0.0.1}" BASE="http://${HOST}:${PORT}" AGENT="auto-agents" PROMPT="Initialize the new environment then enter your main loop forever." HEALTH_TIMEOUT=60 # seconds to wait for the server to become healthy MAX_IDLE_PER_MINUTE=10 # ── Internal state ──────────────────────────────────────────────────────── SERVER_PID="" CURL_PID="" SESSION_ID="" STOP_LOOP=false LAST_SIGINT=0 OWN_SERVER=false N=0 IDLE_EVENTS_FILE="" # ── Helpers ─────────────────────────────────────────────────────────────── die() { printf "ERROR: %s\n" "$*" >&2; exit 1; } log() { printf "[%s] %s\n" "$(date +%H:%M:%S)" "$*"; } check_opencode_running() { # Check if port is open and responding as OpenCode server if curl -sf "${BASE}/global/health" >/dev/null 2>&1; then return 0 # OpenCode server is running else return 1 # No OpenCode server detected fi } cleanup() { if [[ -n "$IDLE_EVENTS_FILE" ]]; then rm -f "$IDLE_EVENTS_FILE" 2>/dev/null || true fi if $OWN_SERVER && [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then log "Stopping opencode server (PID $SERVER_PID)..." kill "$SERVER_PID" 2>/dev/null wait "$SERVER_PID" 2>/dev/null || true elif ! $OWN_SERVER; then log "Leaving existing OpenCode server running (not managed by this script)" fi } handle_sigint() { local now now=$(date +%s) # Second Ctrl+C within 2 seconds → force-quit if $STOP_LOOP && (( now - LAST_SIGINT <= 2 )); then printf "\nForce quit!\n" [[ -n "$CURL_PID" ]] && kill "$CURL_PID" 2>/dev/null || true cleanup exit 130 fi # First Ctrl+C → graceful stop after current request STOP_LOOP=true LAST_SIGINT=$now printf "\nWill stop after the current request completes. Ctrl+C again within 2s to force-quit.\n" } trap handle_sigint INT trap cleanup EXIT # ── Idle-event tracking ─────────────────────────────────────────────────── init_idle_tracking() { IDLE_EVENTS_FILE="/tmp/opencode-builder-idle-events-${SESSION_ID}.txt" : > "$IDLE_EVENTS_FILE" 2>/dev/null || true } record_idle_event() { local now now=$(date +%s) echo "$now" >> "$IDLE_EVENTS_FILE" } count_idle_events_last_minute() { local now cutoff now=$(date +%s) cutoff=$((now - 60)) if [[ -f "$IDLE_EVENTS_FILE" ]]; then awk -v c="$cutoff" '$1 > c {count++} END {print count+0}' "$IDLE_EVENTS_FILE" 2>/dev/null else echo 0 fi } # ── Session lifecycle ───────────────────────────────────────────────────── create_session() { log "Creating session..." SESSION_ID=$( curl -sf -X POST "${BASE}/session" \ -H "Content-Type: application/json" \ -d '{"title":"auto-agents"}' \ | jq -r '.id' ) || die "Failed to create session" [[ -n "$SESSION_ID" && "$SESSION_ID" != "null" ]] || die "Session ID is empty or null" log "Session ID: ${SESSION_ID}" init_idle_tracking } delete_session() { local sid="$1" if [[ -n "$sid" ]]; then curl -sf -X DELETE "${BASE}/session/${sid}" >/dev/null 2>&1 || true fi } restart_session() { log "Session appears broken (>${MAX_IDLE_PER_MINUTE} idle events/min). Restarting..." local old_sid="$SESSION_ID" SESSION_ID="" N=0 delete_session "$old_sid" create_session send_message "$INIT_BODY" log "Initial prompt complete after restart." } # ── Question handling ───────────────────────────────────────────────────── # Queries the OpenCode /question endpoint and rejects any pending questions # that belong to the supplied session. Returns the number of questions that # were successfully dismissed. dismiss_pending_questions() { local sid="$1" local questions_json dismissed qid questions_json=$(curl -sf "${BASE}/question" 2>/dev/null) || return 1 dismissed=0 for qid in $(echo "$questions_json" | jq -r --arg sid "$sid" '.[] | select(.sessionID == $sid) | .id'); do log "Dismissing question $qid" if curl -sf -X POST "${BASE}/question/${qid}/reject" >/dev/null 2>&1; then dismissed=$((dismissed + 1)) fi done echo "$dismissed" } # ── Preflight checks ───────────────────────────────────────────────────── for cmd in curl jq opencode; do command -v "$cmd" &>/dev/null || die "'$cmd' is required but not found in PATH" done # ── Start the opencode server ───────────────────────────────────────────── # Check if OpenCode server is already running if check_opencode_running; then echo "┌─────────────────────────────────────────────────────────────────┐" echo "│ ⚠️ WARNING: OpenCode server already running on ${HOST}:${PORT}" echo "│ 🔗 Connecting to existing server instead of starting new one" echo "│ 💡 The script will NOT manage this server's lifecycle" echo "└─────────────────────────────────────────────────────────────────┘" echo "" log "Using existing OpenCode server at ${HOST}:${PORT}" OWN_SERVER=false else # Start our own server as before log "Starting opencode server on ${HOST}:${PORT}..." opencode serve --port "$PORT" --hostname "$HOST" & SERVER_PID=$! OWN_SERVER=true log "Waiting for server to become healthy (up to ${HEALTH_TIMEOUT}s)..." healthy=false for (( i = 1; i <= HEALTH_TIMEOUT; i++ )); do if curl -sf "${BASE}/global/health" >/dev/null 2>&1; then healthy=true break fi kill -0 "$SERVER_PID" 2>/dev/null || die "Server process exited unexpectedly" sleep 1 done $healthy || die "Server did not become healthy within ${HEALTH_TIMEOUT}s" log "Server is ready." fi # ── Create a session ────────────────────────────────────────────────────── create_session # ── send_message ────────────────────────────────────────────────────────── # Sends a JSON body to POST /session/:id/message and blocks until the LLM # responds. curl is run in a subprocess that ignores SIGINT (SIG_IGN is # inherited across exec), so a single Ctrl+C cannot kill the in-flight # request. The parent re-waits whenever its own trap handler interrupts # the `wait` builtin. send_message() { local body=$1 ( trap '' INT exec curl -sf -X POST "${BASE}/session/${SESSION_ID}/message" \ -H "Content-Type: application/json" \ -d "$body" \ -o /dev/null ) & CURL_PID=$! # Keep waiting until curl actually exits — `wait` can return early when # our SIGINT handler fires, so we loop on kill -0 to re-wait. while kill -0 "$CURL_PID" 2>/dev/null; do wait "$CURL_PID" 2>/dev/null || true done CURL_PID="" } # ── Send the initial prompt ────────────────────────────────────────────── INIT_BODY=$(jq -nc --arg a "$AGENT" --arg t "$PROMPT" \ '{agent: $a, parts: [{type: "text", text: $t}]}') log "Sending initial prompt (agent: ${AGENT})..." log "Prompt: ${PROMPT}" echo "" send_message "$INIT_BODY" log "Initial prompt complete." # ── Continue loop ───────────────────────────────────────────────────────── CONT_BODY=$(jq -nc --arg a "$AGENT" '{agent: $a, parts: [{type: "text", text: "continue with your mainloop, monitor the supervisors, keep them alive, and sleep in an infinite loop."}]}') while ! $STOP_LOOP; do N=$((N + 1)) # Check idle-event rate in the last minute idle_count=$(count_idle_events_last_minute) if (( idle_count > MAX_IDLE_PER_MINUTE )); then restart_session continue fi log "── iteration #${N} ──" # Inspect messages: if a question is waiting for this session, dismiss it; # otherwise issue the standard continue command. dismissed=0 if dismissed_raw=$(dismiss_pending_questions "$SESSION_ID"); then dismissed="$dismissed_raw" fi if (( dismissed > 0 )); then log "Dismissed ${dismissed} pending question(s)." record_idle_event else send_message "$CONT_BODY" log "continue #${N} complete." record_idle_event fi done echo "" log "Loop stopped. Shutting down." exit 0