master
John Lauer Publish 1.3.9 236c5b0 21d ago
#!/usr/bin/env bash
# claude-rc -- keep Claude Code Remote Control (`claude rc`) alive on an Adom container.
#
# One script, several verbs. Everything is idempotent and safe to call often.
#
#   claude-rc hook      the UserPromptSubmit hook. Fast + silent in the common case.
#                       Offers setup once (prints one line into the model's context),
#                       and on hc (no cron) doubles as the watchdog.
#   claude-rc check     the watchdog: launch if absent, with cooldown after failures.
#   claude-rc enable    record consent, register cron on ah, launch now.
#   claude-rc ensure    what hydrogen-bootstrap calls on every converge: turn it on by
#                       default (respects decline/disable, quiet when not ready yet).
#   claude-rc decline   record "no thanks"; remote control stays off on this container.
#   claude-rc disable   stop the server, drop cron, stop watching (re-enable any time).
#   claude-rc status    where things stand (state, pid, name, env, last log lines).
#   claude-rc logs      tail the server log.
#
# Environments (see the ah-vs-hc note in SKILL.md):
#   ah  Adom Hydrogen desktop container: cron is available -> real 2-minute watchdog.
#   hc  Hydrogen Cloud: no cron, no systemd -> the prompt hook is the watchdog.
#
# Tunables (env):
#   CLAUDE_RC_PROJECT_DIR   directory the server runs in   (default $HOME/project)
#   CLAUDE_RC_NAME          session name shown on claude.ai (default <container>-rc)
#   CLAUDE_RC_DISABLE       any non-empty value makes every verb a no-op
set -u
[ -n "${CLAUDE_RC_DISABLE:-}" ] && exit 0
export PATH="$HOME/.local/bin:$PATH"

STATE_DIR="$HOME/.adom"
STATE="$STATE_DIR/claude-rc.state"
LOG="$STATE_DIR/claude-rc.log"
SELF="$HOME/.local/bin/claude-rc"
PROJECT_DIR="${CLAUDE_RC_PROJECT_DIR:-$HOME/project}"
# Anchored to the START of the cmdline so a shell whose command text merely
# mentions "claude rc" (this script's own callers, a chat transcript) never matches.
# The path prefix is OPTIONAL: launched from PATH the cmdline is a bare `claude rc ...`,
# and the old mandatory `\S*/` never matched it, so the watchdog relaunched a server that
# was already up (five times an hour -> state=failed) and `status` said "not running"
# next to a live pid. Verified on an Adom cloud container 2026-08-30.
PAT='^(\S*/)?claude (rc|remote-control)( |$)'
mkdir -p "$STATE_DIR"

# ---- state file: KEY=VALUE lines -------------------------------------------
st_get() { [ -f "$STATE" ] && sed -n "s/^$1=//p" "$STATE" | tail -1; }
st_set() { # st_set KEY VALUE
  local tmp="$STATE.tmp.$$"
  { [ -f "$STATE" ] && grep -v "^$1=" "$STATE"; echo "$1=$2"; } > "$tmp" 2>/dev/null
  mv "$tmp" "$STATE"
}
st_del() {
  local tmp="$STATE.tmp.$$"
  [ -f "$STATE" ] || return 0
  grep -v "^$1=" "$STATE" > "$tmp" || true
  mv "$tmp" "$STATE"
}
now() { date +%s; }
state() { local s; s="$(st_get state)"; echo "${s:-unasked}"; }

# ---- environment -------------------------------------------------------------
is_ah() { [ "${ADOM_DESKTOP_MODE:-}" = "local" ]; }
has_cron() { command -v crontab >/dev/null 2>&1 && { pgrep -x cron >/dev/null 2>&1 || pgrep -x crond >/dev/null 2>&1; }; }
env_name() { if is_ah; then echo ah; else echo hc; fi; }
container_name() {
  local n="${ADOM_CONTAINER_NAME:-$(hostname 2>/dev/null || echo adom)}"
  echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-\n' '-'
}
rc_name() { echo "${CLAUDE_RC_NAME:-$(container_name)-rc}"; }

running_pid() { pgrep -f "$PAT" | head -1; }
# TERM, wait up to 8s for a graceful exit, then KILL. `claude rc` takes a moment to
# tear down its relay connection, so a bare pkill + immediate pgrep still sees it.
stop_server() {
  # Kill the server's whole group (wrapper, subshell, its sleep), not pids: killing the wrapper
  # by pid orphaned its `sleep infinity` forever (1.3.9).
  local pid pg; pid="$(running_pid)"
  [ -n "$pid" ] && pg="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')"
  pkill -f "$PAT" 2>/dev/null || { reap_orphans; return 0; }
  [[ "${pg:-}" =~ ^[0-9]+$ ]] && [ "$pg" -gt 1 ] && kill -- -"$pg" 2>/dev/null
  local i; for i in 1 2 3 4 5 6 7 8; do [ -z "$(running_pid)" ] && { reap_orphans; return 0; }; sleep 1; done
  pkill -9 -f "$PAT" 2>/dev/null || true; sleep 1
  reap_orphans
}

# Prerequisites `claude rc` needs: the CLI, a logged-in account, and the workspace
# trust dialog accepted for PROJECT_DIR. A UserPromptSubmit hook fires from inside a
# session so these normally already hold; the check matters for Codex callers.
# Claude Code's own first-run state lives in ~/.claude.json: hasCompletedOnboarding, a
# theme, and per-folder hasTrustDialogAccepted. Claude REWRITES that file when it signs in,
# dropping whatever seeded it, and then `claude rc` sits on the trust prompt forever. So we
# do not merely CHECK those keys, we write the missing ones ourselves (never overwriting a
# value the user already set). arav-rog 2026-09-23: a fresh install lost the flags at
# sign-in, prereqs read "missing", and remote control never came up for a whole day.
seed_claude_flags() {
  command -v python3 >/dev/null 2>&1 || return 0
  python3 - "$HOME/.claude.json" "$PROJECT_DIR" <<'PY' 2>/dev/null || true
import json, os, sys
path, proj = sys.argv[1], sys.argv[2]
try:
    d = json.load(open(path))
except Exception:
    d = {}
changed = False
for k, v in (("hasCompletedOnboarding", True), ("theme", "dark")):
    if k not in d:
        d[k] = v; changed = True
pr = d.setdefault("projects", {}).setdefault(proj, {})
if not pr.get("hasTrustDialogAccepted"):
    pr["hasTrustDialogAccepted"] = True; changed = True
if changed:
    tmp = path + ".claude-rc.tmp"
    json.dump(d, open(tmp, "w"), indent=2)
    os.replace(tmp, path)
PY
}

# Is an account signed in? `claude rc` needs one; without it the server starts and dies.
signed_in() {
  [ -s "$HOME/.claude/.credentials.json" ] && return 0
  [ -n "${ANTHROPIC_API_KEY:-}" ] && return 0
  command -v python3 >/dev/null 2>&1 || return 1
  python3 - "$HOME/.claude/settings.json" <<'PY' 2>/dev/null
import json, sys
try:
    d = json.load(open(sys.argv[1]))
except Exception:
    sys.exit(1)
sys.exit(0 if (d.get("env") or {}).get("ANTHROPIC_API_KEY") else 1)
PY
}

prereqs_ok() {
  command -v claude >/dev/null 2>&1 || return 1
  [ -d "$PROJECT_DIR" ] || return 1
  signed_in || return 1
  seed_claude_flags
  command -v python3 >/dev/null 2>&1 || return 0
  python3 - "$HOME/.claude.json" "$PROJECT_DIR" <<'PY' 2>/dev/null
import json, sys
try:
    d = json.load(open(sys.argv[1]))
except Exception:
    sys.exit(1)
p = d.get("projects", {}).get(sys.argv[2], {})
sys.exit(0 if p.get("hasTrustDialogAccepted") else 1)
PY
}

# ---- cron (ah only) ----------------------------------------------------------
CRON_CMD="$SELF check"
cron_register() {
  has_cron || return 1
  { crontab -l 2>/dev/null | grep -vF "$SELF" || true
    echo "@reboot $CRON_CMD >/dev/null 2>&1"
    echo "*/2 * * * * $CRON_CMD >/dev/null 2>&1"; } | crontab - 2>/dev/null
}
cron_remove() {
  command -v crontab >/dev/null 2>&1 || return 0
  if crontab -l 2>/dev/null | grep -qF "$SELF"; then
    { crontab -l 2>/dev/null | grep -vF "$SELF" || true; } | crontab - 2>/dev/null
  fi
}
cron_registered() { crontab -l 2>/dev/null | grep -qF "$CRON_CMD"; }

# ---- launch / watchdog ---------------------------------------------------------
rotate_log() {
  [ -f "$LOG" ] && [ "$(stat -c %s "$LOG" 2>/dev/null || echo 0)" -gt 5000000 ] && mv "$LOG" "$LOG.1"
  return 0
}
# REAP LEFTOVER SERVER GROUPS (John 2026-09-29, AdomLapper: four restarts in a day left four
# wrapper shells, each still holding its `sleep infinity`). The server runs as
# `{ printf "y\n"; sleep infinity; } | exec claude rc` in its own session (setsid). When claude rc
# dies, the sleep half never learns it (it never writes, so it never sees SIGPIPE), and the wrapper
# waits on it forever. Before a launch, kill every such group whole, sparing the group of a server
# that is actually alive. Group kill (`kill -- -PGID`) takes the wrapper, the subshell and the sleep
# together, which a pid kill cannot: the sleep would just be reparented and keep running.
reap_orphans() {
  local live_pg="" me_pg pg pid
  pid="$(running_pid)"
  [ -n "$pid" ] && live_pg="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')"
  me_pg="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')"
  for pg in $(pgrep -f '^bash -c \{ printf "y.n"; sleep infinity; \} \| exec claude rc' 2>/dev/null \
              | xargs -r -n1 ps -o pgid= -p 2>/dev/null | tr -d ' ' | sort -u); do
    [[ "$pg" =~ ^[0-9]+$ ]] && [ "$pg" -gt 1 ] || continue
    [ "$pg" = "$live_pg" ] || [ "$pg" = "$me_pg" ] && continue
    kill -- -"$pg" 2>/dev/null || true
  done
  # Older versions killed the wrapper by pid and left its `sleep infinity` orphaned alone in the
  # dead group. Reap those too, narrowly: argv exactly "sleep infinity", reparented to init, its
  # group leader gone, and running in our project dir (where launch starts the server).
  for pid in $(pgrep -x sleep 2>/dev/null); do
    [ "$(tr '\0' ' ' < /proc/"$pid"/cmdline 2>/dev/null)" = "sleep infinity " ] || continue
    [ "$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')" = "1" ] || continue
    pg="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')"
    [ -n "$pg" ] && [ "$pg" != "$pid" ] && [ "$pg" != "$live_pg" ] || continue
    [ -e /proc/"$pg" ] && continue
    [ "$(readlink /proc/"$pid"/cwd 2>/dev/null)" = "$(cd "$PROJECT_DIR" 2>/dev/null && pwd -P)" ] || continue
    kill "$pid" 2>/dev/null || true
  done
}

spawn_server() {
  reap_orphans
  # STDIN MUST STAY OPEN. `claude rc` is a TUI that waits on a keypress ("space to show QR
  # code"); with </dev/null it connects, reads EOF, and exits within a second (the log shows
  # "Connected" five times, then the crash-loop guard marks it failed). A pipe that never
  # closes (sleep infinity) gives it a stdin with no data and no EOF, so it runs until killed.
  # The pipeline is wrapped in its own bash so `exec` keeps the pgrep pattern on the real
  # server process, not on the wrapper.
  # And the CONSENT LINE MUST BE FED: Claude Code 2.1.251 (the VS Code extension's bundled
  # binary on an Adom Hydrogen workspace) prints "Enable Remote Control? (y/n)" even when
  # stdin is not a TTY and waits for the answer, so a silent pipe leaves it "running" but
  # never connected. One "y" answers it; a build that does not ask ignores the byte.
  # The daemon must not inherit the watchdog lock.
  setsid nohup bash -c '{ printf "y\n"; sleep infinity; } | exec claude rc --name "$1"' _ "$(rc_name)" 9>&- >> "$LOG" 2>&1 &
}
launch() {
  cd "$PROJECT_DIR" || return 1
  # Check the budget BEFORE starting. After five starts in an hour, permit
  # one probe every 15 minutes. A temporary outage must never latch us off.
  local t; t="$(now)"
  local hist; hist="$(st_get launches)"
  local kept=""; local x; local n=0; local latest=0
  for x in ${hist//,/ }; do
    [[ "$x" =~ ^[0-9]+$ ]] || continue
    if [ "$x" -le "$t" ] && [ $((t - x)) -lt 3600 ]; then
      kept="${kept:+$kept,}$x"; n=$((n + 1))
      [ "$x" -gt "$latest" ] && latest="$x"
    fi
  done
  if [ "$n" -ge 5 ]; then
    local retry_at=$((latest + 900))
    if [ "$t" -lt "$retry_at" ]; then
      if [ "$(state)" != failed ]; then
        st_set state failed
        st_set failed_at "$t"
        st_set fail_reason "$(tr -d '\r' < "$LOG" 2>/dev/null | grep -v '^\s*$' | grep -v 'claude-rc:' | tail -3 | tr '\n' ' ' | cut -c1-300)"
        echo "[$(date -Is)] claude-rc: restart budget exhausted; cooling down until epoch $retry_at, then retrying automatically." >> "$LOG"
      fi
      st_set retry_after "$retry_at"
      return 2
    fi
  fi
  rotate_log
  echo "[$(date -Is)] claude-rc: launching (name=$(rc_name), env=$(env_name))" >> "$LOG"
  spawn_server
  st_set launches "${kept:+$kept,}$t"
  st_set last_launch "$t"
  st_set state enabled
  st_del retry_after
  return 0
}
# A RUNNING server holds the account it started with. Sign Claude Code into a different
# account (or refresh an expired login) and the old process keeps serving the OLD identity:
# `status` says running, the log says Ready, and the box simply never appears in the new
# account's device list. ConfRoomROG 2026-09-23: the server had been up since Sep 19 while
# the credentials were rewritten that morning, and John could not see the box from his phone.
# So: credentials newer than the process = stale identity, restart it.
server_identity_stale() {
  local pid="$1" creds="$HOME/.claude/.credentials.json" age started cmtime
  [ -n "$pid" ] || return 1
  [ -s "$creds" ] || return 1
  age="$(ps -o etimes= -p "$pid" 2>/dev/null | tr -d ' ')"
  [ -n "$age" ] || return 1
  started=$(( $(date +%s) - age ))
  cmtime="$(stat -c %Y "$creds" 2>/dev/null || echo 0)"
  # 60s of slack: the server writes nothing to the creds file, but a login that happens a
  # moment before the launch must not look newer than it.
  [ "$cmtime" -gt $(( started + 60 )) ]
}

restart_server() {
  local pid="$1"
  [ -n "$pid" ] || return 0
  # The `printf y | claude rc` keeper shares the server's group: kill the group whole so its
  # wrapper and `sleep infinity` go with it (1.3.9; a pid kill left the sleep behind).
  local pg; pg="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')"
  if [[ "$pg" =~ ^[0-9]+$ ]] && [ "$pg" -gt 1 ] && [ "$pg" != "$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')" ]; then
    kill -- -"$pg" 2>/dev/null || true
  else
    kill "$pid" 2>/dev/null || true
  fi
  sleep 3
  launch
}

check() {
  case "$(state)" in enabled|failed) ;; *) return 0 ;; esac
  local pid; pid="$(running_pid)"
  if [ -n "$pid" ] && server_identity_stale "$pid"; then
    echo "claude-rc: Claude was signed in again after this server started; restarting it so remote control uses the current account."
    restart_server "$pid"
    return $?
  fi
  if [ -n "$(running_pid)" ]; then
    # Older keepers marked the fifth attempt failed even if it stayed up.
    if [ "$(state)" = failed ]; then
      st_set state enabled
      st_del retry_after
    fi
    return 0
  fi
  launch; return $?
}

# ---- verbs ---------------------------------------------------------------------
# Turn remote control on without a word to the user or the model: record the state, register
# the cron watchdog where there is one, and start the server in the background. No sleep here:
# this runs inside the prompt hook, so it must return at once.
auto_enable_quiet() {
  st_set state enabled
  st_set enabled_at "$(now)"
  st_set enabled_by auto
  st_set launches ""
  st_del retry_after
  st_set name "$(rc_name)"
  if is_ah || has_cron; then cron_register >/dev/null 2>&1 || true; fi
  [ -n "$(running_pid)" ] || launch >/dev/null 2>&1 || true
}

cmd_hook() {
  case "$(state)" in
    enabled|failed)
      local result=0
      if [ -z "$(running_pid)" ]; then
        launch || result=$?
        if [ "$result" -eq 0 ]; then
          echo "adom: Starting Claude Remote Control as '$(rc_name)'; 'claude-rc logs' confirms whether it connects."
        elif [ "$result" -ne 2 ]; then
          echo "adom: Claude Remote Control could not start in $PROJECT_DIR; 'claude-rc status' shows its prerequisites."
        fi
      else
        check
      fi ;;
    unasked)
      # ON BY DEFAULT, NEVER ASKED (John 2026-09-28: "never have the user get prompted and just
      # make sure claude rc is on"). The old offer ran before a prompt at most once a day on every
      # container that was not already enabled, and it kept coming back (a fresh install where
      # Claude signs in after setup, every hc container). Now the first prompt after Claude is
      # signed in simply turns it on, silently. A human "claude-rc disable" or "decline" still
      # wins and is never overridden.
      prereqs_ok || exit 0
      auto_enable_quiet ;;
    *) : ;;  # declined / disabled: silent
  esac
  if [ "$(state)" = failed ]; then
    # Report a cooldown at most once every 6h; cron/the next prompt retries it.
    local last; last="$(st_get fail_reported)"; local t; t="$(now)"
    if [ -z "$last" ] || [ $((t - last)) -gt 21600 ]; then
      st_set fail_reported "$t"
      echo "adom: Claude Remote Control is cooling down after repeated exits; the watchdog will retry automatically after epoch $(st_get retry_after). Last output: $(st_get fail_reason)."
    fi
  fi
  exit 0
}

cmd_enable() {
  st_set state enabled
  st_set enabled_at "$(now)"
  st_set launches ""
  st_del retry_after
  st_set name "$(rc_name)"
  local how="prompt-hook watchdog (hc: no cron here)"
  if is_ah || has_cron; then
    if cron_register; then how="cron watchdog every 2 min + @reboot (ah)"; else how="prompt-hook watchdog (cron unavailable)"; fi
  fi
  if [ -n "$(running_pid)" ]; then
    echo "claude-rc: enabled; server already running (pid $(running_pid)); supervision: $how"
  else
    launch || true
    sleep 4
    if [ -n "$(running_pid)" ]; then
      echo "claude-rc: enabled and running as '$(rc_name)' (pid $(running_pid)); supervision: $how. It appears on claude.ai/code and the Claude mobile app within about a minute."
    else
      echo "claude-rc: enabled, but the server exited right away. Last output:"; tr -d '\r' < "$LOG" | grep -v '^\s*$' | tail -5
      echo "Usually: not logged in ('claude' first), or workspace trust not accepted in $PROJECT_DIR."
    fi
  fi
}
# ---- ensure: remote control is ON by default on an Adom Hydrogen container ----------
# John 2026-09-23, after two boxes ran all day without it: have the bootstrap enable this on
# an ah container by default, so a fresh install has remote control from day one without
# anyone answering a prompt. Called by every bootstrap converge: quiet, cheap, idempotent.
# It NEVER overrides a human answer (declined/disabled stay). Since 1.3.8 it runs on every
# container (hc too), and the prompt hook no longer offers: it turns remote control on itself.
cmd_ensure() {
  local verbose="${1:-}"
  case "$(state)" in
    declined|disabled) [ "$verbose" = "--verbose" ] && echo "claude-rc: $(state) by the user; leaving it alone."; return 0 ;;
    enabled)
      if [ -n "$(running_pid)" ]; then
        [ "$verbose" = "--verbose" ] && echo "claude-rc: already running (pid $(running_pid))."
      else
        check
      fi
      return 0 ;;
  esac
  if ! prereqs_ok; then
    # Not signed in yet is the normal case on a brand-new box: stay `unasked` and try again on
    # the next converge, so remote control starts as soon as the user signs Claude Code in.
    [ "$verbose" = "--verbose" ] && echo "claude-rc: prerequisites not met yet (sign in to Claude Code); will try again next sweep."
    return 0
  fi
  cmd_enable
}

cmd_decline() { st_set state declined; st_set declined_at "$(now)"; cron_remove; echo "claude-rc: declined. Remote control stays off here; 'claude-rc enable' turns it on later."; }
cmd_disable() {
  st_set state disabled; cron_remove
  local p; p="$(running_pid)"
  if [ -n "$p" ]; then
    stop_server
    echo "claude-rc: disabled; stopped server pid $p and removed cron entries."
  else
    echo "claude-rc: disabled; cron entries removed."
  fi
}
cmd_status() {
  local p; p="$(running_pid)"
  echo "state:       $(state)"
  echo "env:         $(env_name) (ADOM_DESKTOP_MODE=${ADOM_DESKTOP_MODE:-unset}, cron: $(if has_cron; then echo yes; else echo no; fi), cron registered: $(if cron_registered; then echo yes; else echo no; fi))"
  echo "server:      $(if [ -n "$p" ]; then echo "running pid $p"; else echo "not running"; fi)"
  echo "name:        $(rc_name)"
  echo "project dir: $PROJECT_DIR"
  echo "prereqs:     $(if prereqs_ok; then echo ok; else echo "missing (claude on PATH + workspace trust accepted in $PROJECT_DIR)"; fi)"
  if [ "$(state)" = failed ]; then
    echo "fail reason: $(st_get fail_reason)"
    local retry; retry="$(st_get retry_after)"
    if [[ "$retry" =~ ^[0-9]+$ ]]; then
      echo "auto retry:  $(date -d "@$retry" -Is) (next watchdog check after this time)"
    else
      echo "auto retry:  next watchdog check evaluates the previous launch budget"
    fi
  fi
  echo "log:         $LOG"
  [ -f "$LOG" ] && { echo "--- last lines:"; tr -d '\r' < "$LOG" | grep -v '^\s*$' | tail -4; }
  return 0
}
cmd_logs() { [ -f "$LOG" ] && tr -d '\r' < "$LOG" | grep -v '^\s*$' | tail -${1:-40}; }

main() {
  # Cron and prompt hooks share state and a launch decision. Keep these atomic.
  case "${1:-status}" in
    hook|check|enable|decline|disable)
      exec 9>"$STATE_DIR/claude-rc.lock"
      if ! command -v flock >/dev/null 2>&1; then
        echo "claude-rc: flock is required for safe supervision" >&2; return 1
      fi
      flock -w 10 9 || return 1 ;;
  esac
  case "${1:-status}" in
  hook)    cmd_hook ;;
  check)   check ;;
  enable)  cmd_enable ;;
  ensure)  cmd_ensure "${2:-}" ;;
  decline) cmd_decline ;;
  disable) cmd_disable ;;
  status)  cmd_status ;;
  logs)    cmd_logs "${2:-40}" ;;
  -h|--help|help) sed -n 2,24p "$0" ;;
  *) echo "claude-rc: unknown verb '$1' (hook|check|ensure|enable|decline|disable|status|logs)" >&2; exit 2 ;;
esac
}

if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then main "$@"; fi