#!/usr/bin/env bash
# Keeps the Telegram channel (`claude --channels`) alive AND makes sure it
# never fails silently. The channel session is known to exit on its own in
# headless/detached contexts — an upstream Claude Code bug (anthropics/
# claude-code #36477, #40726), not a provisioning fault. This loop reconciles
# to healthy every 10s.
#
# Deploy contract: this file is installed at ~/.claude/telegram-supervisor.sh
# and run by systemd as user `agentic`. Do not assume a cwd — every path
# below is either $HOME-absolute or an explicit `cd`.
#
# Why a "real" health check: process-alive is not enough. The channel can be
# up (tmux + bridge + pid file all present) while the Claude session inside
# it can't act — most commonly an expired login ("Not logged in"). Relaunching
# does not fix that, so we only relaunch on a genuine "down", and we ALWAYS
# alert on state transitions via the Telegram Bot API directly (not through
# the Claude session), because a dead session can't be the one to say it's
# dead. See _overview.md (vps-channel-visibility) for the shared 5-point
# health definition this implements.
#
# IMPORTANT: on relaunch, we must wait for the OLD `claude --channels` process
# to fully exit before starting a new one — Telegram allows only ONE getUpdates
# consumer per bot token. If the old process is still shutting down when the
# new one starts polling, both hit 409 Conflict and die, causing a restart
# loop that looks like random instability but is actually a kill/relaunch race.
set -uo pipefail

SESSION="agentic-tg"
HOME_DIR="${HOME:-/home/agentic}"
AOS_DIR="$HOME_DIR/agentic-os"
ENV_FILE="$AOS_DIR/.env"
DEBUG_LOG="$HOME_DIR/goremote-channel-debug.log"
BOT_PID_FILE="$HOME_DIR/.claude/channels/telegram/bot.pid"
LAUNCHER="$HOME_DIR/.claude/launch-telegram-channel.sh"

# Marker file recording the byte offset of $DEBUG_LOG at the moment of the
# last (re)launch, so "since the last launch" is a real boundary even though
# Claude's --debug-file keeps appending across restarts rather than
# truncating. Lives next to the pid file (same dir the channel already owns).
LAUNCH_MARKER="$HOME_DIR/.claude/channels/telegram/supervisor-launch-offset"

# Recent-error window for check 5 ("Not logged in" / "turn ended in error").
RECENT_WINDOW_SECS=120

# Crash-loop breaker: N restarts inside WINDOW_SECS trips it.
CRASHLOOP_MAX=3
CRASHLOOP_WINDOW_SECS=900

# Boot grace: after a relaunch, how long "down" is treated as "still
# booting" instead of triggering another reconcile. Field forensics (09 Jul,
# 40 restarts in one day's log, 21 messages in → only 4 replies out) showed
# the dominant message-killer was the supervisor itself: a fresh session
# takes up to ~90s to reach "Channel notifications registered" on a loaded
# box, health_check reads that window as "down" every 10s tick, and the
# resulting kill/relaunch loop eats any message delivered meanwhile. The
# stamp is written by reconcile() at relaunch; also written by the systemd
# unit's own startup path implicitly (first tick after service start finds
# no stamp → treated as within grace, see supervisor_main_loop) so a
# service restart doesn't insta-kill the session it inherited.
BOOT_GRACE_SECS=180
RELAUNCH_STAMP="$HOME_DIR/.claude/channels/telegram/supervisor-last-relaunch"
# Rolling file of epoch timestamps, one per relaunch, pruned to the window.
RESTART_LOG="$HOME_DIR/.claude/channels/telegram/supervisor-restarts"

# Where we remember the last alerted state, so alerts fire only on
# transitions (never every 10s tick while steady).
STATE_FILE="$HOME_DIR/.claude/channels/telegram/supervisor-state"

# Byte offset for the lost-reply scan (check_lost_reply), independent of
# LAUNCH_MARKER: this one must advance every tick regardless of relaunches,
# so the same incident is never re-scanned (and re-alerted) on the next 10s
# tick before the log rolls over into a new launch.
LOSTREPLY_MARKER="$HOME_DIR/.claude/channels/telegram/supervisor-lostreply-offset"

# check_reply_timeout — how long we give ANY incoming message to get a real
# reply before we stop trusting health_check's "healthy" and force a
# relaunch. Catches failure modes that leave no error in the debug log at
# all (confirmed live 08 Jul: the session stays up, `/mcp` shows the
# telegram server "failed", nothing crashes, nothing logs — just silence).
REPLY_TIMEOUT_SECS=40
# Tracks "<message_id> <epoch first seen unanswered>" for the newest pending
# message from the allowed user.
REPLY_PENDING_FILE="$HOME_DIR/.claude/channels/telegram/supervisor-reply-pending"
# Tracks the message_id we already alerted+relaunched for, so a still-stuck
# message doesn't re-trigger every 10s tick once we've acted on it once.
REPLY_ALERTED_FILE="$HOME_DIR/.claude/channels/telegram/supervisor-reply-alerted"

mkdir -p "$HOME_DIR/.claude/channels/telegram" 2>/dev/null || true

# ---------------------------------------------------------------------------
# Telegram alerting — direct to the Bot API, independent of the Claude
# session. This is the whole point: it still works when the session is dead.
# ---------------------------------------------------------------------------

# read_env_var KEY — prints the value of KEY from $ENV_FILE, same parsing
# convention go-remote.sh uses when it writes the file (strip quotes/spaces).
read_env_var() {
  local key="$1"
  [ -f "$ENV_FILE" ] || return 1
  grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '[:space:]"'
}

# telegram_alert TEXT — sends TEXT to the allowed Telegram user via the raw
# Bot API. Never touches the Claude session. Best-effort: a failed alert must
# not crash the supervisor loop (transient network blip on the box).
telegram_alert() {
  local text="$1"
  local token chat_id
  token="$(read_env_var TELEGRAM_BOT_TOKEN)" || token=""
  chat_id="$(read_env_var TELEGRAM_ALLOWED_USERS)" || chat_id=""
  if [ -z "$token" ] || [ -z "$chat_id" ]; then
    return 1
  fi
  curl -sS -m 10 "https://api.telegram.org/bot${token}/sendMessage" \
    --data-urlencode "chat_id=${chat_id}" \
    --data-urlencode "text=${text}" \
    >/dev/null 2>&1
}

# user_message_waiting — prints "1" if the allowed user has a message pending,
# else "0". Call ONLY when the bridge is confirmed DOWN: nothing else is
# polling then, so this getUpdates won't 409 with the bridge. It PEEKS only
# (no `offset` param, so Telegram does not confirm/consume the updates), which
# means the bridge still receives them when it comes back — we never eat the
# user's message. Best-effort: any failure prints "0" (stay silent rather than
# alert on a guess). Used to gate the crash-loop alert on "someone is actually
# waiting", so a box nobody is using never pings at 3am.
user_message_waiting() {
  local token chat_id resp
  token="$(read_env_var TELEGRAM_BOT_TOKEN)" || token=""
  chat_id="$(read_env_var TELEGRAM_ALLOWED_USERS)" || chat_id=""
  if [ -z "$token" ] || [ -z "$chat_id" ]; then echo 0; return 0; fi
  resp="$(curl -sS -m 10 "https://api.telegram.org/bot${token}/getUpdates?limit=20&timeout=0" 2>/dev/null || echo "")"
  # Telegram returns compact JSON; a message from our user carries the id in
  # its "from"/"chat" object. Match the id as a whole JSON number value.
  if printf '%s' "$resp" | grep -qE "\"(from|chat)\":\{\"id\":${chat_id}[,}]"; then
    echo 1
  else
    echo 0
  fi
}

# ---------------------------------------------------------------------------
# check_reply_timeout — the general "you are never left in silence" guard.
# Every tick: is there a message from the allowed user that Telegram still
# hasn't seen consumed, and has it been sitting unanswered past
# REPLY_TIMEOUT_SECS? If so, tell the user directly (independent of the
# Claude session — same principle as telegram_alert) and force a relaunch,
# instead of waiting for health_check to notice on its own.
#
# Why this instead of matching another error signature: every failure mode
# found live on 08 Jul (mid-send crash, mid-think crash, and — worst — the
# session staying up with the telegram MCP server silently marked "failed",
# zero log trace) all look identical from the outside: a message went in, no
# real reply came out. This watches for THAT shape directly, so a new,
# not-yet-seen failure mode is caught the same way, no new signature to add
# later.
#
# Uses the same unconfirmed-getUpdates peek as user_message_waiting (no
# `offset`, so this never consumes the user's message) — safe to call on
# every tick regardless of whether the bridge is up or down; a short
# `timeout=0` poll doesn't hold Telegram's one-consumer slot the way a real
# long-poll would. Requires `jq` (already present on Ubuntu 24.04's llvm/npm
# toolchain sets we install, but not guaranteed on every distro) — degrades
# to a silent no-op if missing, since a half-parsed alert is worse than none.
#
# Known gap: if a message is consumed (Telegram's offset advances) WITHOUT
# ever being handed to Claude — the exact "burst message vanishes during a
# boot race" failure also seen live today — it disappears from this peek
# before REPLY_TIMEOUT_SECS can catch it. That specific case is check_lost_
# reply's job (it watches the debug log, not Telegram's queue); the two
# checks are complementary, not redundant.
check_reply_timeout() {
  local token chat_id resp
  token="$(read_env_var TELEGRAM_BOT_TOKEN)" || token=""
  chat_id="$(read_env_var TELEGRAM_ALLOWED_USERS)" || chat_id=""
  if [ -z "$token" ] || [ -z "$chat_id" ]; then return 0; fi
  command -v jq >/dev/null 2>&1 || return 0

  resp="$(curl -sS -m 10 "https://api.telegram.org/bot${token}/getUpdates?limit=50&timeout=0" 2>/dev/null || echo "")"
  [ -n "$resp" ] || return 0

  # Newest update from the allowed user still sitting unconsumed. Matches on
  # chat.id OR from.id (DMs have both equal; belt-and-suspenders either way).
  local latest
  latest="$(printf '%s' "$resp" | jq -r --arg cid "$chat_id" '
    [.result[]? | select(.message != null)
      | select((.message.chat.id|tostring)==$cid or (.message.from.id|tostring)==$cid)]
    | sort_by(.message.date) | last
    | if . == null then empty else "\(.message.message_id)\t\(.message.date)" end
  ' 2>/dev/null)"

  local now
  now="$(date +%s)"

  if [ -z "$latest" ]; then
    # Nothing unconsumed right now — either nobody's waiting, or the real
    # poller already picked it up. Either way, no message is stuck FROM OUR
    # VANTAGE POINT; drop any stale tracking so a resolved episode doesn't
    # linger.
    rm -f "$REPLY_PENDING_FILE" 2>/dev/null || true
    return 0
  fi

  local msgid msg_date
  msgid="$(printf '%s' "$latest" | cut -f1)"
  msg_date="$(printf '%s' "$latest" | cut -f2)"
  case "$msgid" in ''|*[!0-9]*) return 0 ;; esac

  local pending_msgid="" pending_since=0
  if [ -f "$REPLY_PENDING_FILE" ]; then
    read -r pending_msgid pending_since < "$REPLY_PENDING_FILE" 2>/dev/null || true
  fi
  case "$pending_since" in ''|*[!0-9]*) pending_since=0 ;; esac

  if [ "$msgid" != "$pending_msgid" ]; then
    # A new "still unconsumed" message became the newest one — start (or
    # restart) the clock on THIS message, not the message's own Telegram
    # timestamp (skew between the two is fine; we only care how long WE'VE
    # observed it sitting unanswered).
    echo "$msgid $now" > "$REPLY_PENDING_FILE" 2>/dev/null || true
    return 0
  fi

  local elapsed=$((now - pending_since))
  [ "$elapsed" -ge "$REPLY_TIMEOUT_SECS" ] || return 0

  local already_alerted=""
  [ -f "$REPLY_ALERTED_FILE" ] && already_alerted="$(cat "$REPLY_ALERTED_FILE" 2>/dev/null)"
  [ "$already_alerted" = "$msgid" ] && return 0

  # Last chance to confirm this really is unanswered: did a reply/react/
  # edit_message tool call complete successfully AFTER we started waiting?
  # (Catches the case where the reply landed but Telegram's own getUpdates
  # view lagged behind — avoids a false alarm on a slow-but-working turn.)
  local log_size=0
  [ -f "$DEBUG_LOG" ] && log_size="$(wc -c <"$DEBUG_LOG" 2>/dev/null | tr -d '[:space:]')"
  case "$log_size" in ''|*[!0-9]*) log_size=0 ;; esac
  local last_success_ts
  last_success_ts="$(tail -c 200000 "$DEBUG_LOG" 2>/dev/null \
    | grep -E 'tool_dispatch_end tool=mcp__plugin_telegram_telegram__(reply|react|edit_message).*outcome=ok' \
    | tail -1 | grep -oE '^[0-9-]+T[0-9:.]+Z' || true)"
  if [ -n "$last_success_ts" ]; then
    local last_success_epoch
    last_success_epoch="$(date -u -d "$last_success_ts" +%s 2>/dev/null || echo 0)"
    if [ "$last_success_epoch" -ge "$pending_since" ]; then
      rm -f "$REPLY_PENDING_FILE" 2>/dev/null || true
      return 0
    fi
  fi

  telegram_alert "⚠️ I haven't answered your last message in over ${REPLY_TIMEOUT_SECS}s — something's wrong on my end. Restarting automatically now, please resend in about a minute." || true
  echo "$msgid" > "$REPLY_ALERTED_FILE" 2>/dev/null || true
  reconcile || true
}

# ---------------------------------------------------------------------------
# Health check — the shared 5-point definition. Returns one of three states
# on stdout: healthy | down | mute-auth. Takes the debug log path and the
# launch-marker offset as arguments so it is unit-testable with fixtures
# instead of real tmux/pgrep/curl.
#
# Args:
#   $1 = debug log path
#   $2 = launch marker (byte offset) path
#   $3 = tmux-session-present flag: "1" or "0" (real check when omitted)
#   $4 = server.ts-proc-present flag: "1" or "0" (real check when omitted)
#   $5 = bot.pid-present flag: "1" or "0" (real check when omitted)
#   $6 = bot pid file path (real check when omitted)
# ---------------------------------------------------------------------------
health_check() {
  local log_path="${1:-$DEBUG_LOG}"
  local marker_path="${2:-$LAUNCH_MARKER}"
  local tmux_flag="${3:-}"
  local proc_flag="${4:-}"
  local pid_flag="${5:-}"
  local pid_file_path="${6:-$BOT_PID_FILE}"

  local tmux_up=0 proc_up=0 pid_up=0

  if [ -n "$tmux_flag" ]; then
    [ "$tmux_flag" = "1" ] && tmux_up=1
  else
    tmux has-session -t "$SESSION" 2>/dev/null && tmux_up=1
  fi

  if [ -n "$proc_flag" ]; then
    [ "$proc_flag" = "1" ] && proc_up=1
  else
    pgrep -f '[s]erver\.ts' >/dev/null 2>&1 && proc_up=1
  fi

  if [ -n "$pid_flag" ]; then
    [ "$pid_flag" = "1" ] && pid_up=1
  else
    [ -f "$pid_file_path" ] && pid_up=1
  fi

  # Checks 1-3: process-alive. Any miss = down, no point reading the log.
  if [ "$tmux_up" -ne 1 ] || [ "$proc_up" -ne 1 ] || [ "$pid_up" -ne 1 ]; then
    echo "down"
    return 0
  fi

  # From here, process-alive holds. Read only the portion of the log written
  # since the last launch (byte offset in the marker), so a stale success
  # line from a prior boot never counts as "registered this time", and a
  # stale error line from before a successful relaunch never falsely trips
  # mute-auth.
  local since=0
  [ -f "$marker_path" ] && since="$(cat "$marker_path" 2>/dev/null)"
  case "$since" in ''|*[!0-9]*) since=0 ;; esac

  local log_size=0
  [ -f "$log_path" ] && log_size="$(wc -c <"$log_path" 2>/dev/null | tr -d '[:space:]')"
  case "$log_size" in ''|*[!0-9]*) log_size=0 ;; esac
  [ "$since" -gt "$log_size" ] && since=0

  local tail_content=""
  if [ -f "$log_path" ]; then
    tail_content="$(tail -c "+$((since + 1))" "$log_path" 2>/dev/null || true)"
  fi

  # Check 4: the bridge actually registered notifications since launch.
  local registered=0
  printf '%s' "$tail_content" | grep -q "Channel notifications registered" && registered=1

  if [ "$registered" -ne 1 ]; then
    echo "down"
    return 0
  fi

  # Check 5: no recent auth/turn failure. "Recent" = within RECENT_WINDOW_SECS,
  # determined by the log file's mtime — if the last write to the log is
  # older than the window, whatever error it contains is stale, not active.
  local log_mtime now age
  if [ -f "$log_path" ]; then
    log_mtime="$(stat -c %Y "$log_path" 2>/dev/null || echo 0)"
  else
    log_mtime=0
  fi
  now="$(date +%s)"
  age=$((now - log_mtime))

  local recent_failure=0
  if [ "$age" -le "$RECENT_WINDOW_SECS" ]; then
    if printf '%s' "$tail_content" | grep -qE "Not logged in|turn ended in error"; then
      recent_failure=1
    fi
  fi

  if [ "$recent_failure" -eq 1 ]; then
    echo "mute-auth"
    return 0
  fi

  echo "healthy"
  return 0
}

# ---------------------------------------------------------------------------
# check_lost_reply — a signal health_check can't see: a message came IN, and
# the session ended before a reply/react/edit_message went OUT for it.
# Confirmed live TWICE in the same field session (08 Jul), two different
# proximate causes, same shape — a message arrives, the session is still
# working the turn, then it exits (SIGINT to its own MCP server as part of
# shutdown) before the outgoing tool call ever completes:
#   1. Mid-send: the reply tool was called, the MCP transport closed while it
#      was in flight ("MCP error -32000: Connection closed").
#   2. Mid-think: the session was still running tool calls (never got to
#      reply yet) when it hit a fatal terminal I/O error ("EIO: i/o error,
#      write") and exited.
# Root cause in both cases is upstream (Anthropic's plugin advances the
# Telegram offset on delivery, not on confirmed reply, so either crash loses
# the message for good; not ours to patch). What IS ours: the user hearing
# nothing. Rather than chase every distinct crash signature, this watches the
# one thing that's actually true in every case — "message in" not followed by
# "reply out" before the session ends — so it catches this failure mode
# regardless of why the session died.
#
# State machine over new log content since the last call (own offset marker,
# independent of LAUNCH_MARKER so a relaunch never re-scans/re-alerts the
# same incident): a "notifications/claude/channel:" line opens a pending
# turn; a completed reply/react/edit_message (outcome=ok) closes it; a
# "Sending SIGINT to MCP server process" line while a turn is still pending
# means the session ended without answering — that's the alert. Reaching
# end-of-scan with a turn still pending and NO exit yet is not alerted (the
# session may just be slow — wait for either the answer or the exit on a
# later tick). Returns "1" if it alerted on a fresh occurrence, "0"
# otherwise. Always advances the offset to end-of-file, so each byte of log
# is scanned exactly once (a still-pending turn at end-of-scan is picked up
# again next tick because its "notifications/claude/channel:" line is only
# used to seed state within a single scan, not persisted across calls — see
# note below on the rare double-alert this trades off against silence).
check_lost_reply() {
  local log_path="${1:-$DEBUG_LOG}"
  local marker_path="${2:-$LOSTREPLY_MARKER}"

  local since=0
  [ -f "$marker_path" ] && since="$(cat "$marker_path" 2>/dev/null)"
  case "$since" in ''|*[!0-9]*) since=0 ;; esac

  local log_size=0
  [ -f "$log_path" ] && log_size="$(wc -c <"$log_path" 2>/dev/null | tr -d '[:space:]')"
  case "$log_size" in ''|*[!0-9]*) log_size=0 ;; esac
  [ "$since" -gt "$log_size" ] && since=0

  local found=0
  if [ "$log_size" -gt "$since" ] && [ -f "$log_path" ]; then
    # pending is a COUNTER, not a flag: with interleaved messages (A in, B
    # in, reply-to-A out) a boolean would read the single reply as "all
    # answered" and stay silent about B dying with the session.
    if tail -c "+$((since + 1))" "$log_path" 2>/dev/null | awk '
      /notifications\/claude\/channel:/ { pending++; next }
      /tool_dispatch_end tool=mcp__plugin_telegram_telegram__(reply|react|edit_message).*outcome=ok/ { if (pending > 0) pending--; next }
      /Sending SIGINT to MCP server process/ {
        if (pending > 0) { print "LOST"; exit }
        next
      }
    ' | grep -q LOST; then
      found=1
    fi
  fi

  echo "$log_size" >"$marker_path" 2>/dev/null || true

  if [ "$found" -eq 1 ]; then
    telegram_alert "⚠️ I may have lost the connection while answering your last message — it might not have gone through. If you don't see a reply, please resend it." || true
    echo "1"
  else
    echo "0"
  fi
}

# ---------------------------------------------------------------------------
# Crash-loop bookkeeping
# ---------------------------------------------------------------------------

# record_restart — appends "now" to $RESTART_LOG and prunes anything older
# than CRASHLOOP_WINDOW_SECS.
record_restart() {
  local now
  now="$(date +%s)"
  {
    [ -f "$RESTART_LOG" ] && awk -v cutoff="$((now - CRASHLOOP_WINDOW_SECS))" '$1+0 >= cutoff' "$RESTART_LOG"
    echo "$now"
  } >"${RESTART_LOG}.tmp" 2>/dev/null
  mv -f "${RESTART_LOG}.tmp" "$RESTART_LOG" 2>/dev/null || true
}

# restart_count_in_window — prints how many restarts landed inside the
# crash-loop window right now (no side effects, used for the breach check).
restart_count_in_window() {
  local now
  now="$(date +%s)"
  if [ -f "$RESTART_LOG" ]; then
    awk -v cutoff="$((now - CRASHLOOP_WINDOW_SECS))" '$1+0 >= cutoff' "$RESTART_LOG" | wc -l | tr -d '[:space:]'
  else
    echo 0
  fi
}

# ---------------------------------------------------------------------------
# Reconcile — relaunch via the CORRECT launcher (settings-only), keeping the
# 409-race wait. NEVER falls back to scripts/telegram.sh (bare launch = mute
# bot, the whole bug this task exists to prevent).
# ---------------------------------------------------------------------------
reconcile() {
  tmux kill-session -t "$SESSION" 2>/dev/null || true
  pkill -TERM -f 'claude .*--channels' 2>/dev/null || true
  pkill -TERM -f '[s]erver\.ts' 2>/dev/null || true
  # Wait for BOTH the claude process and the bridge to actually be gone
  # (up to 10s) before relaunching — this is the fix for the 409-restart race.
  for _ in $(seq 1 20); do
    pgrep -f 'claude .*--channels' >/dev/null 2>&1 || pgrep -f '[s]erver\.ts' >/dev/null 2>&1 || break
    sleep 0.5
  done
  # Belt-and-suspenders: force-kill anything that's still hanging on.
  pkill -KILL -f 'claude .*--channels' 2>/dev/null || true
  pkill -KILL -f '[s]erver\.ts' 2>/dev/null || true
  rm -f "$BOT_PID_FILE"

  # Snapshot the debug log's current size as the new launch marker BEFORE
  # relaunching, so the next health_check only looks at lines written by
  # this launch (never a stale "registered"/"Not logged in" from before).
  local log_size=0
  [ -f "$DEBUG_LOG" ] && log_size="$(wc -c <"$DEBUG_LOG" 2>/dev/null | tr -d '[:space:]')"
  case "$log_size" in ''|*[!0-9]*) log_size=0 ;; esac
  echo "$log_size" >"$LAUNCH_MARKER" 2>/dev/null || true

  sleep 1
  # Degrade continuity to guarantee availability: the launcher resumes the
  # previous conversation (--continue) by default, but a resumed boot can
  # skip channel registration entirely (live 09 Jul: resume boots with no
  # MCP connection attempt — deaf bot, alive session). If we're already on
  # the 2nd+ relaunch inside the crash-loop window, the resume path is the
  # likely culprit: launch FRESH (GOREMOTE_FRESH=1 drops --continue). Costs
  # the chat context, keeps the bot answering.
  local _n
  _n="$(restart_count_in_window)"
  case "$_n" in ''|*[!0-9]*) _n=0 ;; esac
  if [ "$_n" -ge 2 ]; then
    tmux new-session -d -s "$SESSION" "GOREMOTE_FRESH=1 bash $LAUNCHER"
  else
    tmux new-session -d -s "$SESSION" "bash $LAUNCHER"
  fi
  record_restart
  echo "$(date +%s)" >"$RELAUNCH_STAMP" 2>/dev/null || true
}

# ---------------------------------------------------------------------------
# Main loop. Every tick classifies state and reconciles when the state calls
# for it (never on mute-auth). Alerts are REACTIVE: the user only hears from
# us when there's evidence they tried to use the bot and it couldn't answer
# (a failed turn = a message came in; or, when the bridge is down, a message
# actually waiting). A transient drop that self-heals stays silent. A single
# bad tick (health-check hiccup, curl failure) must never kill the loop, every
# step below is defensive against that.
# ---------------------------------------------------------------------------
supervisor_main_loop() {
  prev_state=""
  [ -f "$STATE_FILE" ] && prev_state="$(cat "$STATE_FILE" 2>/dev/null || echo "")"
  breaker_tripped=0
  # Set when we send ANY alert during a bad episode; gates the "back online"
  # confirmation so it only fires if the user was actually told it broke.
  # Reset when we return to healthy.
  alerted_episode=0

  while true; do
    # Runs every tick regardless of state: a lost-reply can happen while the
    # process still looks "healthy" for a beat before the crash it causes
    # shows up as "down" on the next tick. Independent alert, doesn't touch
    # alerted_episode/breaker bookkeeping below (different failure mode).
    check_lost_reply >/dev/null 2>&1 || true
    # check_reply_timeout is DISABLED here — field-tested 08 Jul and it made
    # things worse: it peeks getUpdates every tick, but Telegram allows only
    # ONE getUpdates listener per bot, so each peek cancels the bridge's own
    # long-poll. Total inbound deafness on the box correlated with its
    # deploy. The function is kept above because its logic (message in, no
    # reply out, act) is right — but it needs an inbound signal that does
    # NOT touch getUpdates while the bridge is up (same rule
    # user_message_waiting already documents). Do not re-enable as-is.

    state="$(health_check 2>/dev/null || echo "down")"

    case "$state" in
      healthy)
        # Only confirm recovery if we alerted this episode (the user was told
        # it broke, so they should know they can retry). A blip that healed
        # itself without ever bugging the user stays silent.
        if [ "$prev_state" != "healthy" ] && [ -n "$prev_state" ] && [ "$alerted_episode" -eq 1 ]; then
          telegram_alert "✅ Your Agentic OS is back online." || true
        fi
        alerted_episode=0
        breaker_tripped=0
        ;;

      mute-auth)
        # Reactive by nature: a turn only errors because a message came IN, so
        # this state already means the user messaged and got no answer. Alert.
        if [ "$prev_state" != "mute-auth" ]; then
          telegram_alert "⚠️ I can't reach Claude on the server: the login expired. Fix: run claude setup-token and re-run the installer." || true
          alerted_episode=1
        fi
        # Do NOT reconcile: relaunching cannot fix an expired login. Stop
        # trying until the next boot (systemd restart) or a manual fix.
        ;;

      down)
        # Boot grace: a relaunched session takes up to ~90s to reach
        # "Channel notifications registered" on a loaded box — during that
        # window health_check honestly reports "down", but acting on it is
        # what caused the kill/relaunch loop that ate most messages (field
        # forensics 09 Jul). Inside the grace window, wait instead of
        # reconciling. A missing stamp (fresh supervisor start — systemd
        # restart inherits a possibly-booting session) counts as in-grace:
        # stamp now and give the inherited session the full window before
        # judging it.
        # Grace only protects a boot that is actually IN PROGRESS — i.e. the
        # session/bridge processes exist but haven't registered yet. When
        # nothing is running at all (fresh systemd start: the whole cgroup,
        # tmux included, was killed), reconcile immediately — its kills are
        # no-ops and waiting would just be a dead window.
        _session_present=0
        tmux has-session -t "$SESSION" 2>/dev/null && _session_present=1
        # The real cmdline is `claude --debug-file ... --channels ...` — a
        # literal 'claude --channels' pattern never matches it.
        pgrep -f 'claude .*--channels' >/dev/null 2>&1 && _session_present=1
        _last_relaunch=0
        if [ -f "$RELAUNCH_STAMP" ]; then
          _last_relaunch="$(cat "$RELAUNCH_STAMP" 2>/dev/null)"
          case "$_last_relaunch" in ''|*[!0-9]*) _last_relaunch=0 ;; esac
        fi
        _now="$(date +%s)"
        if [ "$_session_present" -eq 1 ] && [ "$_last_relaunch" -eq 0 ]; then
          # Inherited a possibly-booting session with no stamp (stamp file
          # lost/first deploy): give it the full window before judging.
          echo "$_now" >"$RELAUNCH_STAMP" 2>/dev/null || true
          _last_relaunch="$_now"
        fi
        if [ "$_session_present" -eq 1 ] && [ $((_now - _last_relaunch)) -lt "$BOOT_GRACE_SECS" ]; then
          : # still booting — let it finish; next ticks re-evaluate
        elif [ "$breaker_tripped" -eq 1 ]; then
          # Already tripped and no reduction in pressure yet — stay quiet
          # (already alerted), don't relaunch, don't re-trip every tick.
          :
        else
          count="$(restart_count_in_window)"
          case "$count" in ''|*[!0-9]*) count=0 ;; esac
          if [ "$count" -ge "$CRASHLOOP_MAX" ]; then
            breaker_tripped=1
            # Only bother the user if someone is actually waiting on the bot.
            # The bridge is down, so nothing else is polling: safe to peek
            # getUpdates. If a message from the allowed user is pending, tell
            # them why; if nobody is waiting, stay silent.
            if [ "$(user_message_waiting)" = "1" ]; then
              telegram_alert "⚠️ The channel went down and didn't come back after several tries. Check the server or re-run the installer." || true
              alerted_episode=1
            fi
          else
            reconcile || true
          fi
        fi
        ;;

      *)
        # Unknown/unexpected classification — treat as down defensively but
        # never crash the loop over it.
        state="down"
        ;;
    esac

    if [ "$state" != "$prev_state" ]; then
      echo "$state" >"$STATE_FILE" 2>/dev/null || true
      prev_state="$state"
    fi

    sleep 10
  done
}

# Only auto-run the infinite loop when executed directly (systemd's
# ExecStart does exactly this). When sourced — e.g. by the isolated test
# suite, to reuse health_check/telegram_alert/record_restart without the
# loop — this is a no-op, so tests can call the functions directly.
if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
  supervisor_main_loop
fi
