#!/usr/bin/env bash # Restart an agent whose Claude session is parked at an expired login. # # ./sylph-watchdog watch forever (run detached) # ./sylph-watchdog --once one pass, for cron or a manual check # ./sylph-watchdog --status what it would do right now, changing nothing # # Env: SYLPH_WATCH_INTERVAL (default 300s), SYLPH_WATCH_CONTAINERS # # ── Why this exists ───────────────────────────────────────────────────────── # # 🔴 `--restart unless-stopped` DOES NOT COVER THIS, and that is the whole point. # Docker restarts a container that EXITS. A Claude session sitting at # # Login expired · Please run /login # # never exits. The process is healthy, the container is Up, `docker ps` is green, # and the agent has done nothing for hours. Three times now (2026-08-30, # 09-02, 09-03) that has been noticed only because a human saw Remote Control # report "Can't reach your computer" — which is a symptom of the session being # unable to attach, not a report about the machine. # # The fix is already in the entrypoint: it copies the host's credentials in when # they are newer than the container's. It just needs something to notice and # bounce the container. That is all this does. # # ⚠️ It restarts rather than logging in. A restart re-runs the entrypoint, which # re-seeds credentials AND resumes the session by id, so the agent keeps its # context. There is nothing here that could log a session in on its own, and it # should not pretend to: if the HOST's credentials are also stale, this loop will # bounce the container and the agent will park again. It says so instead of # retrying silently. set -uo pipefail CONTAINERS="${SYLPH_WATCH_CONTAINERS:-sylpheed-agent sylpheed-port}" INTERVAL="${SYLPH_WATCH_INTERVAL:-300}" # How far back to look. Longer than the interval so a stall spanning two passes # is still seen, short enough that a login expiry cured an hour ago does not # read as current. WINDOW="${SYLPH_WATCH_WINDOW:-20m}" log() { printf '[watchdog %s] %s\n' "$(date -u '+%H:%M:%S')" "$*"; } # Has this container printed an expiry recently, and NOT recovered since? # # "Recovered" matters: the string stays in the log forever, so a bare grep would # restart a healthy agent every pass on the strength of an hours-old line. The # test is whether the transcript has been written SINCE the last expiry — a # working agent writes constantly. parked() { local c="$1" docker ps --filter "name=^${c}$" --format '{{.Names}}' | grep -q . || return 1 local hits hits=$(docker logs --since "$WINDOW" "$c" 2>&1 \ | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' \ | grep -c 'Login expired' 2>/dev/null || true) [ "${hits:-0}" -gt 0 ] || return 1 # Transcript idle for longer than one interval => it really is stuck. A busy # agent that merely logged an expiry and recovered keeps writing. local age age=$(docker exec "$c" bash -lc ' f=$(ls -1t "$HOME/.claude/projects"/*/*.jsonl 2>/dev/null | head -1) [ -n "$f" ] && echo $(( $(date +%s) - $(stat -c %Y "$f") )) || echo 999999 ' 2>/dev/null | tr -d '[:space:]') case "$age" in ''|*[!0-9]*) age=999999 ;; esac [ "$age" -gt "$INTERVAL" ] } # Is the HOST's copy actually newer? If not, a restart cannot help and saying so # is the useful output — otherwise this becomes a loop that bounces a container # every five minutes and reports success. host_is_newer() { local c="$1" docker exec "$c" bash -lc ' s="$HOME/.claude.seed/.credentials.json"; o="$HOME/.claude/.credentials.json" [ -e "$s" ] || exit 2 [ ! -e "$o" ] || [ "$s" -nt "$o" ] ' >/dev/null 2>&1 } pass() { local acted=0 for c in $CONTAINERS; do if parked "$c"; then if host_is_newer "$c"; then log "$c is parked at an expired login; host credentials are newer -- restarting" [ "${1:-}" = "--status" ] || docker restart "$c" >/dev/null 2>&1 \ && log "$c restarted (entrypoint re-seeds and resumes the session)" else log "🔴 $c is parked at an expired login and the HOST's credentials are" log " NO NEWER. A restart cannot fix this -- log in on the host first." fi acted=1 fi done [ "$acted" = 0 ] && log "all watched agents are alive" return 0 } # ── The control, EXECUTED ─────────────────────────────────────────────────── # # 🔴 A watchdog that has never fired is a hope, not a guard. Its whole value is # in the true-positive path, and that path only runs when an agent is already # broken -- so it gets a synthetic one. # # Two cases against real containers, because the detection is `docker logs` plus # `docker exec` and neither can be reasoned about from the shell: # # a container printing "Login expired" with no transcript -> parked (TRUE positive) # a live agent -> not parked (negative) # # ⚠️ Written after claiming, wrongly and without checking, that a bare grep # "would have fired" on a recovered container. The count was zero. That is the # same error this whole corpus keeps cataloguing -- asserting what an instrument # would have said instead of running it -- so the instrument now runs. selftest() { local ok=0 name="sylph-watchdog-control-$$" echo "control:" docker run -d --rm --name "$name" alpine:latest \ sh -c 'echo "Login expired · Please run /login"; sleep 120' >/dev/null 2>&1 # Give docker a moment to have the line available in the log. for _ in 1 2 3 4 5; do docker logs "$name" 2>&1 | grep -q 'Login expired' && break sleep 1 done if SYLPH_WATCH_CONTAINERS="$name" parked "$name"; then printf ' %-46s ✅\n' "an expired login with no transcript reads PARKED" else printf ' %-46s 🔴\n' "an expired login with no transcript reads PARKED"; ok=1 fi # And it must NOT fire on the same container once it is gone -- a stopped # container is not a parked one, and restarting it would be wrong. docker rm -f "$name" >/dev/null 2>&1 if parked "$name"; then printf ' %-46s 🔴\n' "a container that is gone reads NOT parked"; ok=1 else printf ' %-46s ✅\n' "a container that is gone reads NOT parked" fi # The live negative, against whatever is actually running. local live=0 for c in $CONTAINERS; do docker ps --filter "name=^${c}$" --format '{{.Names}}' | grep -q . || continue live=1 if parked "$c"; then printf ' %-46s 🔴 (%s)\n' "a working agent reads NOT parked" "$c"; ok=1 else printf ' %-46s ✅ (%s)\n' "a working agent reads NOT parked" "$c" fi done [ "$live" = 1 ] || printf ' %-46s -- no agent running\n' "a working agent reads NOT parked" echo [ $ok -eq 0 ] && echo "the watchdog fires on a parked session and not otherwise" \ || echo "🔴 the watchdog cannot tell parked from alive" return $ok } case "${1:-}" in --once) pass ;; --status) pass --status ;; --selftest) selftest; exit $? ;; *) log "watching [$CONTAINERS] every ${INTERVAL}s" while true; do pass sleep "$INTERVAL" done ;; esac