app
hook
Public Made by Adomby adom
Registry-native cross-agent auto-update hook, plus claude-rc, the keeper for Claude Remote Control. Keeps adom/core apps and skills current from wiki.adom.inc on every Claude Code and Codex prompt, and keeps `claude rc` alive so users can open AI threads on their container from the Claude mobile app or claude.ai/code.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
#!/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