#!/usr/bin/env bash
# daemon-session — durable lifecycle for hyper-tps --daemon-only sessions.
# Replaces ad-hoc `setsid hyper-tps --daemon-only … & / pkill / hand-typed admin-cli`.
# See spec/daemon-session.md. Self-contained; scp to any fleet machine and run.
#
# daemon-session start <roomId|prefix> [--mode room|anchor|pin] [--expect-identity <name>]
# daemon-session stop <slug>
# daemon-session status [<slug>]
# daemon-session restart <slug>
# daemon-session logs <slug> [-f]
# daemon-session query <slug> <method> [jsonParams]
#
# Conventions: ~/.hyper-tps/sessions/<slug>/{pid(=process-group id),log,meta.json}
set -uo pipefail
SESS_ROOT="${HYPER_TPS_SESSIONS:-$HOME/.hyper-tps/sessions}"
LAUNCHER="${HYPER_TPS_LAUNCHER:-$HOME/.local/bin/hyper-tps}"
SOCK="${KEET_SOCK:-/tmp/keet-daemon.sock}"
_die() { echo "daemon-session: $*" >&2; exit 1; }
_slug() { echo "$1" | tr -cd '[:alnum:]' | cut -c1-24; }
_sdir() { echo "$SESS_ROOT/$(_slug "$1")"; }
# First existing admin-cli.cjs (source tree, then installed app tree).
_resolve_cli() {
local c
for c in "$(dirname "$0")/admin-cli.cjs" \
"$HOME/.local/share/store/apps/hyper-tps/scripts/admin-cli.cjs" \
"/storage/projects/code/godot/hyper-tps/scripts/admin-cli.cjs"; do
[ -f "$c" ] && { echo "$c"; return 0; }
done
return 1
}
# A raw JSON-RPC to the daemon via admin-cli. Args: method [jsonParams]
_rpc() {
local cli; cli="$(_resolve_cli)" || { echo '{"error":"admin-cli not found"}'; return 1; }
KEET_SOCK="$SOCK" node "$cli" raw "$1" "${2:-{}}" 2>/dev/null
}
# Is process group $1 alive? (kill -0 on the leader pid; pgid == leader pid here)
_alive() { [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; }
_meta_get() { # <sdir> <key>
grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$1/meta.json" 2>/dev/null | head -1 | sed -E 's/.*: *"([^"]*)".*/\1/'
}
cmd_start() {
local room="$1"; shift
local mode="room" expect=""
while [ $# -gt 0 ]; do case "$1" in
--mode) mode="$2"; shift 2;;
--expect-identity) expect="$2"; shift 2;;
*) _die "unknown start flag: $1";;
esac; done
[ -n "$room" ] || _die "start <roomId|prefix> required"
local slug sdir; slug="$(_slug "$room")"; sdir="$(_sdir "$room")"
mkdir -p "$sdir"
# Idempotent: a live session already? (one daemon per ~/.config/keet-cli anyway)
if [ -f "$sdir/pid" ] && _alive "$(cat "$sdir/pid")"; then
echo "already running (slug=$slug pgid=$(cat "$sdir/pid")); use 'restart' to relaunch"
return 0
fi
# Mode → launcher flag. room = game-driven (accepts a PREFIX); anchor/pin =
# daemon-driven (need the FULL roomId; anchor also grows the indexer set).
local flag
case "$mode" in
room) flag="--room-id";;
anchor) flag="--anchor-room";;
pin) flag="--pin-room";;
*) _die "mode must be room|anchor|pin";;
esac
: > "$sdir/log"
# setsid → new process group; the launched pid IS the group-leader (pgid).
DISPLAY="${DISPLAY:-:0}" setsid "$LAUNCHER" --daemon-only "$flag" "$room" \
>> "$sdir/log" 2>&1 < /dev/null &
local pgid=$!
echo "$pgid" > "$sdir/pid"
printf '{"machine":"%s","roomArg":"%s","mode":"%s","flag":"%s","startedAt":"%s","pgid":%s}\n' \
"$(hostname)" "$room" "$mode" "$flag" "$(date -u +%FT%TZ 2>/dev/null || echo unknown)" "$pgid" > "$sdir/meta.json"
echo "started slug=$slug pgid=$pgid mode=$mode room=$room log=$sdir/log"
# Preflight (best-effort, non-fatal): report identity + room open state once up.
local i
for i in $(seq 1 20); do [ -S "$SOCK" ] && grep -qiE 'Active identity|_start_mesh|state-base-v3' "$sdir/log" && break; sleep 3; done
local ident; ident="$(grep -oE 'Active identity: [a-z0-9-]+' "$sdir/log" | tail -1 | awk '{print $3}')"
[ -n "$ident" ] && echo " identity: $ident"
if [ -n "$expect" ] && [ -n "$ident" ] && [ "$ident" != "$expect" ]; then
echo " ⚠ identity MISMATCH: expected '$expect', got '$ident' (NOT switching — see NEVER_CHANGE_IDENTITIES)"
fi
}
cmd_stop() {
local slug sdir; slug="$(_slug "$1")"; sdir="$(_sdir "$1")"
[ -f "$sdir/pid" ] || _die "no session '$slug'"
local pgid; pgid="$(cat "$sdir/pid")"
if _alive "$pgid"; then
kill -TERM "-$pgid" 2>/dev/null # negative = whole process group (no orphans)
local i; for i in $(seq 1 10); do _alive "$pgid" || break; sleep 1; done
_alive "$pgid" && kill -KILL "-$pgid" 2>/dev/null
echo "stopped slug=$slug pgid=$pgid"
else
echo "slug=$slug not running (stale pid $pgid)"
fi
rm -f "$sdir/pid"
}
cmd_status() {
local one="${1:-}"
mkdir -p "$SESS_ROOT"
local sdir slug pgid up
for sdir in "$SESS_ROOT"/*/; do
[ -d "$sdir" ] || continue
slug="$(basename "$sdir")"
[ -n "$one" ] && [ "$slug" != "$(_slug "$one")" ] && continue
pgid="$(cat "$sdir/pid" 2>/dev/null || echo -)"
up=down; _alive "$pgid" && up=UP
printf '● %-24s %-4s pgid=%-7s mode=%-6s room=%s\n' \
"$slug" "$up" "$pgid" "$(_meta_get "$sdir" mode)" "$(_meta_get "$sdir" roomArg)"
if [ "$up" = UP ] && [ -S "$SOCK" ]; then
# Resolve full roomId from the game log, then query state.diag.
local rid; rid="$(grep -oE 'joining room_id=[a-z0-9]+' "$sdir/log" | tail -1 | cut -d= -f2)"
if [ -n "$rid" ]; then
local d; d="$(_rpc state.diag "{\"roomId\":\"$rid\",\"open\":true}")"
echo " indexers=$(echo "$d"|grep -oE '"indexers": [0-9]+'|grep -oE '[0-9]+') writable=$(echo "$d"|grep -oE '"writable": (true|false)'|grep -oE '(true|false)') baseLength=$(echo "$d"|grep -oE '"baseLength": [0-9]+'|grep -oE '[0-9]+') broken=$(echo "$d"|grep -oE '"broken": [^,]*'|sed 's/.*: //')"
fi
fi
done
}
cmd_restart() { cmd_stop "$1" 2>/dev/null; sleep 2
local sdir; sdir="$(_sdir "$1")"
local room mode; room="$(_meta_get "$sdir" roomArg)"; mode="$(_meta_get "$sdir" mode)"
[ -n "$room" ] || _die "no meta to restart '$1'"
cmd_start "$room" --mode "${mode:-room}"
}
cmd_logs() { local sdir; sdir="$(_sdir "$1")"; [ -f "$sdir/log" ] || _die "no log for '$1'"
if [ "${2:-}" = "-f" ]; then tail -f "$sdir/log"; else tail -40 "$sdir/log"; fi; }
cmd_query() { local sdir rid; sdir="$(_sdir "$1")"
rid="$(grep -oE 'joining room_id=[a-z0-9]+' "$sdir/log" 2>/dev/null | tail -1 | cut -d= -f2)"
local params="${3:-{}}"
# Convenience: if params omitted but method needs a room, inject the resolved id.
[ "$params" = "{}" ] && [ -n "$rid" ] && case "$2" in state.*|members*|send|read) params="{\"roomId\":\"$rid\"}";; esac
_rpc "$2" "$params"
}
case "${1:-}" in
start) shift; cmd_start "$@";;
stop) shift; cmd_stop "$@";;
status) shift; cmd_status "$@";;
restart) shift; cmd_restart "$@";;
logs) shift; cmd_logs "$@";;
query) shift; cmd_query "$@";;
*) echo "usage: daemon-session {start <room> [--mode room|anchor|pin] [--expect-identity <name>]|stop <slug>|status [slug]|restart <slug>|logs <slug> [-f]|query <slug> <method> [json]}" >&2; exit 1;;
esac