2026-05-04 23:57:32 +00:00
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) " && pwd ) "
2026-06-18 18:13:46 +00:00
# shellcheck source=scripts/lib/health_probe.sh
. " ${ REPO_ROOT } /scripts/lib/health_probe.sh "
2026-05-04 23:57:32 +00:00
HERMES_HOME = " ${ HERMES_HOME :- ${ HOME } /.hermes } "
PID_FILE = " ${ HERMES_WEBUI_PID_FILE :- ${ HERMES_HOME } /webui.pid } "
LOG_FILE = " ${ HERMES_WEBUI_LOG_FILE :- ${ HERMES_HOME } /webui.log } "
STATE_FILE = " ${ HERMES_WEBUI_CTL_STATE_FILE :- ${ HERMES_HOME } /webui.ctl.env } "
DEFAULT_STATE_DIR = " ${ HERMES_WEBUI_STATE_DIR :- ${ HERMES_HOME } /webui } "
2026-05-31 22:51:22 +00:00
DEFAULT_LAUNCHD_LABEL = " ${ HERMES_WEBUI_LAUNCHD_LABEL :- com .parantoux.hermes-webui } "
2026-05-04 23:57:32 +00:00
usage( ) {
cat <<'EOF'
Usage: ./ctl.sh <command> [ args]
Commands:
start [ bootstrap args...] Start Hermes WebUI as a background daemon
stop Stop the daemon started by ctl.sh
restart [ bootstrap args...] Stop, then start again
status Show daemon, host/port, log, and health status
logs [ --lines N] [ --follow| --no-follow]
Show the daemon log ( defaults to tail -n 100 -f)
EOF
}
ensure_home( ) {
mkdir -p " ${ HERMES_HOME } " " ${ DEFAULT_STATE_DIR } "
}
2026-07-01 23:24:38 +00:00
_apply_env_file_safely( ) {
local env_file = " $1 "
local line key value
while IFS = read -r line || [ [ -n " ${ line } " ] ] ; do
line = " ${ line # ${ line %%[![ : space : ]]* } } "
[ [ -z " ${ line } " || " ${ line } " = = \# * ] ] && continue
if [ [ " ${ line } " = ~ ^export[ [ :space:] ] +( .+) $ ] ] ; then
line = " ${ BASH_REMATCH [1] } "
line = " ${ line # ${ line %%[![ : space : ]]* } } "
fi
[ [ " ${ line } " = = *= * ] ] || continue
key = " ${ line %%=* } "
value = " ${ line #*= } "
key = " ${ key //[[ : space : ]]/ } "
[ [ " ${ key } " = ~ ^[ A-Za-z_] [ A-Za-z0-9_] *$ ] ] || continue
case " ${ key } " in
UID | GID | EUID | EGID | PPID) continue ; ;
esac
value = " ${ value # ${ value %%[![ : space : ]]* } } "
if [ [ " ${ value } " = ~ ^\" ( ( [ ^\" \\ ] | \\ .) *) \" ( [ [ :space:] ] *\# .*) ?[ [ :space:] ] *$ ] ] ; then
value = " ${ BASH_REMATCH [1] } "
value = " $( printf '%s' " $value " | awk ' {
i = 1
len = length( $0 )
while ( i <= len) {
c = substr( $0 , i, 1)
if ( c = = "\\" && i < len) {
nc = substr( $0 , i+1, 1)
if ( nc = = "n" ) printf "\n"
else if ( nc = = "r" ) printf "\r"
else if ( nc = = "t" ) printf "\t"
else if ( nc = = "\"" ) printf "\""
else if ( nc = = "\\" ) printf "\\"
else { printf "\\%s" , nc }
i += 2
} else {
printf "%s" , c
i++
}
}
} ' ) "
elif [ [ " ${ value } " = ~ ^\' ( [ ^\' ] *) \' ( [ [ :space:] ] *\# .*) ?[ [ :space:] ] *$ ] ] ; then
value = " ${ BASH_REMATCH [1] } "
else
value = " ${ value %%[[ : space : ]] \# * } "
value = " ${ value % ${ value ##*[![ : space : ]] } } "
fi
export " ${ key } = ${ value } "
done < " ${ env_file } "
}
2026-05-04 23:57:32 +00:00
_load_repo_dotenv_preserving_env( ) {
2026-06-01 02:39:08 +00:00
[ [ " ${ HERMES_WEBUI_NO_DOTENV :- 0 } " = = "1" ] ] && return 0
2026-05-04 23:57:32 +00:00
local env_file = " ${ REPO_ROOT } /.env "
[ [ -f " ${ env_file } " ] ] || return 0
local -a preserved = ( )
local line key value
while IFS = read -r line || [ [ -n " ${ line } " ] ] ; do
line = " ${ line # ${ line %%[![ : space : ]]* } } "
[ [ -z " ${ line } " || " ${ line } " = = \# * || " ${ line } " != *= * ] ] && continue
key = " ${ line %%=* } "
2026-07-01 23:24:38 +00:00
if [ [ " ${ key } " = ~ ^export[ [ :space:] ] +( .+) $ ] ] ; then
key = " ${ BASH_REMATCH [1] } "
fi
2026-05-04 23:57:32 +00:00
key = " ${ key //[[ : space : ]]/ } "
[ [ " ${ key } " = ~ ^[ A-Za-z_] [ A-Za-z0-9_] *$ ] ] || continue
2026-06-18 18:13:46 +00:00
# Skip shell-readonly names (UID/GID/EUID/EGID/PPID); re-exporting them
# below would abort under `set -euo pipefail` with "readonly variable".
case " ${ key } " in
UID | GID | EUID | EGID | PPID) continue ; ;
esac
2026-05-10 06:29:07 +00:00
if [ [ -n " ${ !key+x } " ] ] ; then
2026-05-04 23:57:32 +00:00
value = " ${ !key } "
preserved += ( " ${ key } = ${ value } " )
fi
done < " ${ env_file } "
2026-07-01 23:24:38 +00:00
_apply_env_file_safely " ${ env_file } "
local assignment
if [ [ ${# preserved [@] } -gt 0 ] ] ; then
for assignment in " ${ preserved [@] } " ; do
export " ${ assignment } "
done
fi
}
_load_hermes_dotenv( ) {
# Also load ~/.hermes/.env so that ${VAR} references in config.yaml can
# resolve against provider credentials defined in the Hermes env file.
# Repo .env takes precedence (loaded above); variables already exported
# into the shell environment (including those just set by repo .env) are
# captured in preserved[] before _apply_env_file_safely runs and are
# restored afterwards, so this acts as a fallback source for vars the
# repo .env did not define.
[ [ " ${ HERMES_WEBUI_NO_DOTENV :- 0 } " = = "1" ] ] && return 0
local hermes_home = " ${ HERMES_HOME :- ${ HOME } /.hermes } "
local hermes_env = " ${ hermes_home } /.env "
[ [ -f " ${ hermes_env } " ] ] || return 0
local -a preserved = ( )
local line key value
while IFS = read -r line || [ [ -n " ${ line } " ] ] ; do
line = " ${ line # ${ line %%[![ : space : ]]* } } "
[ [ -z " ${ line } " || " ${ line } " = = \# * || " ${ line } " != *= * ] ] && continue
key = " ${ line %%=* } "
if [ [ " ${ key } " = ~ ^export[ [ :space:] ] +( .+) $ ] ] ; then
key = " ${ BASH_REMATCH [1] } "
fi
key = " ${ key //[[ : space : ]]/ } "
[ [ " ${ key } " = ~ ^[ A-Za-z_] [ A-Za-z0-9_] *$ ] ] || continue
case " ${ key } " in
UID | GID | EUID | EGID | PPID) continue ; ;
esac
if [ [ -n " ${ !key+x } " ] ] ; then
value = " ${ !key } "
preserved += ( " ${ key } = ${ value } " )
fi
done < " ${ hermes_env } "
_apply_env_file_safely " ${ hermes_env } "
2026-05-04 23:57:32 +00:00
local assignment
2026-05-12 05:04:02 +00:00
if [ [ ${# preserved [@] } -gt 0 ] ] ; then
for assignment in " ${ preserved [@] } " ; do
export " ${ assignment } "
done
fi
2026-05-04 23:57:32 +00:00
}
_find_python( ) {
if [ [ -n " ${ HERMES_WEBUI_PYTHON :- } " ] ] ; then
printf '%s\n' " ${ HERMES_WEBUI_PYTHON } "
elif command -v python3 >/dev/null 2>& 1; then
command -v python3
elif command -v python >/dev/null 2>& 1; then
command -v python
else
echo "[ctl] Python 3 is required to run bootstrap.py" >& 2
return 1
fi
}
_parse_launch_binding( ) {
CTL_HOST = " ${ HERMES_WEBUI_HOST :- 127 .0.0.1 } "
CTL_PORT = " ${ HERMES_WEBUI_PORT :- 8787 } "
local arg next_is_host = 0 saw_port = 0
for arg in " $@ " ; do
if ( ( next_is_host ) ) ; then
CTL_HOST = " ${ arg } "
next_is_host = 0
continue
fi
case " ${ arg } " in
--host)
next_is_host = 1
; ;
--host= *)
CTL_HOST = " ${ arg #--host= } "
; ;
--*)
; ;
*)
if ( ( ! saw_port ) ) && [ [ " ${ arg } " = ~ ^[ 0-9] +$ ] ] ; then
CTL_PORT = " ${ arg } "
saw_port = 1
fi
; ;
esac
done
}
_build_bootstrap_args( ) {
CTL_BOOTSTRAP_ARGS = ( )
local arg next_is_host = 0 saw_port = 0
for arg in " $@ " ; do
if ( ( next_is_host ) ) ; then
next_is_host = 0
continue
fi
case " ${ arg } " in
--host)
next_is_host = 1
; ;
--host= *)
; ;
--*)
CTL_BOOTSTRAP_ARGS += ( " ${ arg } " )
; ;
*)
if ( ( ! saw_port ) ) && [ [ " ${ arg } " = ~ ^[ 0-9] +$ ] ] ; then
saw_port = 1
else
CTL_BOOTSTRAP_ARGS += ( " ${ arg } " )
fi
; ;
esac
done
}
_write_state( ) {
2026-05-13 05:09:42 +00:00
local pid = " $1 " host = " $2 " port = " $3 " python_exe = " ${ 4 :- } "
2026-05-04 23:57:32 +00:00
local state_dir = " ${ HERMES_WEBUI_STATE_DIR :- ${ DEFAULT_STATE_DIR } } "
{
printf 'PID=%q\n' " ${ pid } "
printf 'REPO_ROOT=%q\n' " ${ REPO_ROOT } "
2026-05-13 05:09:42 +00:00
printf 'PYTHON_EXE=%q\n' " ${ python_exe } "
2026-05-04 23:57:32 +00:00
printf 'HOST=%q\n' " ${ host } "
printf 'PORT=%q\n' " ${ port } "
printf 'LOG_FILE=%q\n' " ${ LOG_FILE } "
printf 'STATE_DIR=%q\n' " ${ state_dir } "
printf 'STARTED_AT=%q\n' " $( date -u +%Y-%m-%dT%H:%M:%SZ) "
} > " ${ STATE_FILE } "
}
_load_state_if_present( ) {
if [ [ -f " ${ STATE_FILE } " ] ] ; then
# shellcheck source=/dev/null
source " ${ STATE_FILE } "
fi
}
_pid_from_file( ) {
[ [ -f " ${ PID_FILE } " ] ] || return 1
local pid
pid = " $( tr -d '[:space:]' < " ${ PID_FILE } " ) "
[ [ " ${ pid } " = ~ ^[ 0-9] +$ ] ] || return 1
printf '%s\n' " ${ pid } "
}
_is_alive( ) {
local pid = " $1 "
kill -0 " ${ pid } " >/dev/null 2>& 1
}
2026-06-05 17:53:45 +00:00
_is_windows_bash( ) {
[ [ " ${ OS :- } " = = "Windows_NT" ] ] && return 0
case " $( uname -s 2>/dev/null || true ) " in
MINGW*| MSYS*| CYGWIN*) return 0 ; ;
*) return 1 ; ;
esac
}
_windows_bash_path( ) {
local path = " ${ 1 // \\ // } " drive rest
if [ [ " ${ path } " = ~ ^( [ A-Za-z] ) :( .*) $ ] ] ; then
drive = " ${ BASH_REMATCH [1],, } "
rest = " ${ BASH_REMATCH [2] } "
printf '/%s%s\n' " ${ drive } " " ${ rest } "
return
fi
printf '%s\n' " ${ path } "
}
_windows_pid_for_bash_pid( ) {
2026-05-04 23:57:32 +00:00
local pid = " $1 "
2026-06-05 17:53:45 +00:00
ps -p " ${ pid } " -l 2>/dev/null | awk 'NR == 2 { print $4 }'
}
_stop_webui_pid( ) {
local pid = " $1 " signal = " ${ 2 :- TERM } "
if _is_windows_bash && command -v taskkill >/dev/null 2>& 1; then
local winpid
winpid = " $( _windows_pid_for_bash_pid " ${ pid } " ) "
if [ [ " ${ winpid } " = ~ ^[ 0-9] +$ ] ] ; then
taskkill //F //T //PID " ${ winpid } " >/dev/null 2>& 1 || true
return
fi
fi
if [ [ " ${ signal } " = = "KILL" ] ] ; then
kill -KILL " ${ pid } " >/dev/null 2>& 1 || true
else
kill " ${ pid } " >/dev/null 2>& 1 || true
fi
}
_proc_args( ) {
local pid = " $1 " args
args = " $( ps -p " ${ pid } " -o args = 2>/dev/null || true ) "
if [ [ -n " ${ args } " ] ] ; then
printf '%s\n' " ${ args } "
return
fi
if _is_windows_bash; then
local winpid
winpid = " $( _windows_pid_for_bash_pid " ${ pid } " ) "
if [ [ " ${ winpid } " = ~ ^[ 0-9] +$ ] ] && command -v wmic >/dev/null 2>& 1; then
args = " $( wmic process where " ProcessId= ${ winpid } " get CommandLine //value 2>/dev/null | sed -n 's/^CommandLine=//p' | tr -d '\r' ) "
if [ [ -n " ${ args } " ] ] ; then
printf '%s\n' " ${ args } "
return
fi
fi
ps -p " ${ pid } " -f 2>/dev/null | awk 'NR == 2 { for (i = 8; i <= NF; i++) printf "%s%s", (i == 8 ? "" : " "), $i; print "" }'
fi
2026-05-04 23:57:32 +00:00
}
_is_owned_webui_pid( ) {
2026-06-05 17:53:45 +00:00
local pid = " $1 " args args_slash state_repo = "" state_repo_slash = "" state_repo_win = "" state_repo_win_slash = "" state_python = "" state_python_slash = "" state_python_bash = ""
2026-05-04 23:57:32 +00:00
[ [ -f " ${ STATE_FILE } " ] ] || return 1
_load_state_if_present
state_repo = " ${ REPO_ROOT :- } "
2026-05-13 05:09:42 +00:00
state_python = " ${ PYTHON_EXE :- } "
2026-06-05 17:53:45 +00:00
state_repo_slash = " ${ state_repo // \\ // } "
state_python_slash = " ${ state_python // \\ // } "
if _is_windows_bash; then
state_repo_win = " $( cygpath -w " ${ state_repo } " 2>/dev/null || true ) "
state_repo_win_slash = " ${ state_repo_win // \\ // } "
fi
if [ [ -n " ${ state_python } " ] ] && _is_windows_bash; then
state_python_bash = " $( _windows_bash_path " ${ state_python } " ) "
fi
2026-05-04 23:57:32 +00:00
[ [ " ${ state_repo } " = = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) " && pwd ) " ] ] || return 1
args = " $( _proc_args " ${ pid } " ) "
[ [ -n " ${ args } " ] ] || return 1
2026-06-05 17:53:45 +00:00
args_slash = " ${ args // \\ // } "
[ [ " ${ args_slash } " = = *" ${ state_repo_slash } /bootstrap.py " * ||
" ${ args_slash } " = = *" ${ state_repo_slash } /server.py " * ||
" ${ args_slash } " = = *" ${ state_repo_slash } /start.sh " * ||
( -n " ${ state_repo_win_slash } " && " ${ args_slash } " = = *" ${ state_repo_win_slash } /bootstrap.py " * ) ||
( -n " ${ state_repo_win_slash } " && " ${ args_slash } " = = *" ${ state_repo_win_slash } /server.py " * ) ||
( -n " ${ state_repo_win_slash } " && " ${ args_slash } " = = *" ${ state_repo_win_slash } /start.sh " * ) ||
( -n " ${ state_python } " && " ${ args } " = = *" ${ state_python } " * ) ||
( -n " ${ state_python_slash } " && " ${ args_slash } " = = *" ${ state_python_slash } " * ) ||
( -n " ${ state_python_bash } " && " ${ args_slash } " = = *" ${ state_python_bash } " * ) ] ]
2026-05-04 23:57:32 +00:00
}
_current_pid( ) {
local pid
pid = " $( _pid_from_file) " || return 1
if _is_alive " ${ pid } " && _is_owned_webui_pid " ${ pid } " ; then
printf '%s\n' " ${ pid } "
return 0
fi
return 1
}
_clear_stale_pid( ) {
if [ [ -f " ${ PID_FILE } " ] ] ; then
rm -f " ${ PID_FILE } " " ${ STATE_FILE } "
echo " [ctl] Removed stale PID file: ${ PID_FILE } "
fi
}
2026-05-31 23:08:52 +00:00
_pid_listens_on_port( ) {
# Best-effort check that PID $1 has a listening socket on TCP port $2.
# macOS (where launchd exists) ships lsof; if we can't determine ownership we
# return 2 ("unknown") so the caller can fall back conservatively rather than
# guess. Never blocks on a hard failure.
local pid = " $1 " port = " $2 "
[ [ " ${ pid } " = ~ ^[ 0-9] +$ && " ${ port } " = ~ ^[ 0-9] +$ ] ] || return 2
if command -v lsof >/dev/null 2>& 1; then
if lsof -nP -p " ${ pid } " -iTCP:" ${ port } " -sTCP:LISTEN >/dev/null 2>& 1; then
return 0 # PID is listening on that port → real conflict
fi
return 1 # PID is alive but NOT listening on that port → no conflict
fi
return 2 # can't determine
}
2026-05-31 22:51:22 +00:00
_launchd_webui_pid( ) {
[ [ " ${ HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT :- 0 } " = = "1" ] ] && return 1
command -v launchctl >/dev/null 2>& 1 || return 1
local label = " ${ HERMES_WEBUI_LAUNCHD_LABEL :- ${ DEFAULT_LAUNCHD_LABEL } } "
[ [ -n " ${ label } " ] ] || return 1
local uid launchd_out pid
uid = " $( id -u) "
launchd_out = " $( launchctl print " gui/ ${ uid } / ${ label } " 2>/dev/null) " || return 1
pid = " $( printf '%s\n' " ${ launchd_out } " | awk '/^[[:space:]]*pid = / {print $3; exit}' ) "
[ [ " ${ pid } " = ~ ^[ 0-9] +$ ] ] || return 1
( ( pid > 0 ) ) || return 1
2026-05-31 23:08:52 +00:00
_is_alive " ${ pid } " || return 1
# Only treat the launchd job as a conflict for the port we are about to bind.
# A second instance on a DIFFERENT port (e.g. HERMES_WEBUI_PORT=8788 for a
# test build) does not collide with the launchd-managed default and must be
# allowed to start (#3291 over-block fix). When port ownership can't be
# determined (no lsof), fall back to the conservative previous behavior of
# only guarding the default port so non-default ports are never wrongly blocked.
local want_port = " ${ CTL_PORT :- ${ HERMES_WEBUI_PORT :- 8787 } } "
_pid_listens_on_port " ${ pid } " " ${ want_port } "
case " $? " in
0) printf '%s\n' " ${ pid } " ; return 0 ; ; # launchd job listens on our port → block
1) return 1 ; ; # launchd job on a different port → allow
*) # unknown: only guard the default port
if [ [ " ${ want_port } " = = "8787" ] ] ; then
printf '%s\n' " ${ pid } " ; return 0
fi
return 1 ; ;
esac
2026-05-31 22:51:22 +00:00
}
2026-05-04 23:57:32 +00:00
start_cmd( ) {
ensure_home
_load_repo_dotenv_preserving_env
2026-07-01 23:24:38 +00:00
_load_hermes_dotenv
2026-05-04 23:57:32 +00:00
export HERMES_WEBUI_STATE_DIR = " ${ HERMES_WEBUI_STATE_DIR :- ${ DEFAULT_STATE_DIR } } "
mkdir -p " ${ HERMES_WEBUI_STATE_DIR } "
_parse_launch_binding " $@ "
_build_bootstrap_args " $@ "
export HERMES_WEBUI_HOST = " ${ CTL_HOST } "
export HERMES_WEBUI_PORT = " ${ CTL_PORT } "
local existing_pid
if existing_pid = " $( _current_pid 2>/dev/null) " ; then
echo " [ctl] Hermes WebUI is already running (PID ${ existing_pid } ) "
return 0
fi
2026-05-31 22:51:22 +00:00
local launchd_pid
if launchd_pid = " $( _launchd_webui_pid 2>/dev/null) " ; then
echo " [ctl] Refusing to start a second Hermes WebUI while launchd job ${ HERMES_WEBUI_LAUNCHD_LABEL :- ${ DEFAULT_LAUNCHD_LABEL } } is running (PID ${ launchd_pid } ). " >& 2
echo " [ctl] Use launchctl kickstart -k gui/ $( id -u) / ${ HERMES_WEBUI_LAUNCHD_LABEL :- ${ DEFAULT_LAUNCHD_LABEL } } or disable the launchd job before using ctl.sh start. " >& 2
return 2
fi
2026-05-04 23:57:32 +00:00
_clear_stale_pid >/dev/null 2>& 1 || true
local python_exe pid
python_exe = " $( _find_python) "
: >> " ${ LOG_FILE } "
(
cd " ${ REPO_ROOT } "
2026-05-24 14:52:59 +00:00
trap '' HUP
export HERMES_WEBUI_PRESERVE_ENV = 1
exec nohup " ${ python_exe } " " ${ REPO_ROOT } /bootstrap.py " --no-browser --foreground --host " ${ CTL_HOST } " " ${ CTL_PORT } " ${ CTL_BOOTSTRAP_ARGS [@]+ " ${ CTL_BOOTSTRAP_ARGS [@] } " }
2026-05-04 23:57:32 +00:00
) >> " ${ LOG_FILE } " 2>& 1 &
pid = $!
printf '%s\n' " ${ pid } " > " ${ PID_FILE } "
2026-05-13 05:09:42 +00:00
_write_state " ${ pid } " " ${ CTL_HOST } " " ${ CTL_PORT } " " ${ python_exe } "
2026-05-04 23:57:32 +00:00
sleep 0.15
if ! _is_alive " ${ pid } " ; then
echo " [ctl] Hermes WebUI failed to stay running. Log: ${ LOG_FILE } " >& 2
rm -f " ${ PID_FILE } " " ${ STATE_FILE } "
return 1
fi
echo " [ctl] Started Hermes WebUI (PID ${ pid } ) "
echo " [ctl] Bound: ${ CTL_HOST } : ${ CTL_PORT } "
echo " [ctl] Log: ${ LOG_FILE } "
}
stop_cmd( ) {
ensure_home
local pid
if ! pid = " $( _pid_from_file 2>/dev/null) " ; then
echo "[ctl] Hermes WebUI is stopped"
rm -f " ${ PID_FILE } " " ${ STATE_FILE } "
return 0
fi
if ! _is_alive " ${ pid } " || ! _is_owned_webui_pid " ${ pid } " ; then
_clear_stale_pid
return 0
fi
echo " [ctl] Stopping Hermes WebUI (PID ${ pid } ) "
2026-06-05 17:53:45 +00:00
_stop_webui_pid " ${ pid } " TERM
2026-05-04 23:57:32 +00:00
local i
for i in { 1..50} ; do
if ! _is_alive " ${ pid } " ; then
rm -f " ${ PID_FILE } " " ${ STATE_FILE } "
echo "[ctl] Stopped"
return 0
fi
sleep 0.1
done
echo "[ctl] Process did not exit after SIGTERM; sending SIGKILL" >& 2
2026-06-05 17:53:45 +00:00
_stop_webui_pid " ${ pid } " KILL
2026-05-04 23:57:32 +00:00
rm -f " ${ PID_FILE } " " ${ STATE_FILE } "
}
_health_line( ) {
2026-06-18 18:13:46 +00:00
local host = " $1 " port = " $2 " url scheme result
scheme = " $( hermes_webui_probe_scheme) "
url = " ${ scheme } :// ${ host } : ${ port } /health "
if ! command -v curl >/dev/null 2>& 1 && ! command -v wget >/dev/null 2>& 1; then
echo " unknown (curl/wget not found; ${ url } ) "
return 0
fi
if result = " $( hermes_webui_probe_health " ${ host } " " ${ port } " "/health" 2) " ; then
if command -v python3 >/dev/null 2>& 1; then
printf '%s' " ${ result } " | python3 -c ' import json,sys
2026-05-04 23:57:32 +00:00
try:
data = json.load( sys.stdin)
sessions = data.get( "sessions" , data.get( "session_count" , "?" ) )
active = data.get( "active_streams" , "?" )
status = data.get( "status" , "ok" )
print( f"ok ({sessions} sessions, {active} active streams)" if status = = "ok" else status)
except Exception:
print( "ok" ) '
else
2026-06-18 18:13:46 +00:00
echo "ok"
2026-05-04 23:57:32 +00:00
fi
else
2026-06-18 18:13:46 +00:00
echo " unreachable ( ${ url } ) "
2026-05-04 23:57:32 +00:00
fi
}
status_cmd( ) {
ensure_home
2026-06-18 18:13:46 +00:00
_load_repo_dotenv_preserving_env
2026-07-01 23:24:38 +00:00
_load_hermes_dotenv
2026-05-04 23:57:32 +00:00
_load_state_if_present
local host = " ${ HOST :- ${ HERMES_WEBUI_HOST :- 127 .0.0.1 } } "
local port = " ${ PORT :- ${ HERMES_WEBUI_PORT :- 8787 } } "
local log_path = " ${ LOG_FILE } "
local pid uptime health
if pid = " $( _current_pid 2>/dev/null) " ; then
uptime = " $( ps -p " ${ pid } " -o etime = 2>/dev/null | sed 's/^ *//' || true ) "
health = " $( _health_line " ${ host } " " ${ port } " ) "
echo "● hermes-webui — running"
echo " PID: ${ pid } "
echo " Uptime: ${ uptime :- unknown } "
echo " Bound: ${ host } : ${ port } "
echo " Log: ${ log_path } "
echo " Health: ${ health } "
else
[ [ -f " ${ PID_FILE } " ] ] && _clear_stale_pid >/dev/null 2>& 1 || true
echo "● hermes-webui — stopped"
echo " PID: -"
echo " Bound: ${ host } : ${ port } "
echo " Log: ${ log_path } "
echo " Health: not checked"
fi
}
logs_cmd( ) {
ensure_home
local lines = 100 follow = 1
while [ [ $# -gt 0 ] ] ; do
case " $1 " in
--lines)
shift
lines = " ${ 1 :- } "
[ [ " ${ lines } " = ~ ^[ 0-9] +$ ] ] || { echo "[ctl] --lines requires a number" >& 2; return 2; }
; ;
--lines= *)
lines = " ${ 1 #--lines= } "
[ [ " ${ lines } " = ~ ^[ 0-9] +$ ] ] || { echo "[ctl] --lines requires a number" >& 2; return 2; }
; ;
--follow| -f)
follow = 1
; ;
--no-follow)
follow = 0
; ;
*)
echo " [ctl] Unknown logs option: $1 " >& 2
return 2
; ;
esac
shift
done
touch " ${ LOG_FILE } "
if ( ( follow ) ) ; then
tail -n " ${ lines } " -f " ${ LOG_FILE } "
else
tail -n " ${ lines } " " ${ LOG_FILE } "
fi
}
cmd = " ${ 1 :- } "
if [ [ $# -gt 0 ] ] ; then
shift
fi
case " ${ cmd } " in
start) start_cmd " $@ " ; ;
stop) stop_cmd ; ;
restart) stop_cmd; start_cmd " $@ " ; ;
status) status_cmd ; ;
logs) logs_cmd " $@ " ; ;
-h| --help| help| "" ) usage ; ;
*) echo " [ctl] Unknown command: ${ cmd } " >& 2; usage >& 2; exit 2 ; ;
esac