-
Notifications
You must be signed in to change notification settings - Fork 37
Lich Character Watchdog ‐ macOS
A Bash-based watchdog that ensures a specific Lich character session stays running on macOS. Detects whether the character is currently active using two signals (session file + process check) and relaunches Lich if either is missing. Scheduled via launchd (LaunchAgent).
Note
Windows and Linux instructions live on separate wiki pages:
- How it works
- The macOS tempdir gotcha
- The script
- Configuration
- launchd setup
- Testing
- Troubleshooting
- Running multiple characters
The watchdog considers a character "running" only if both of these are true:
-
Session file present — Lich writes
<tmpdir>/simutronics/sessions/<CharacterName>.sessionwhile connected. On macOS,<tmpdir>is not/tmp— see the next section. -
Matching process alive — A
rubyprocess exists whose command line referenceslichand--login <CharacterName>. If either signal is missing, the watchdog launches Lich with the configured arguments.
| Scenario | Session file | Process | Watchdog action |
|---|---|---|---|
| Normal running session | ✅ | ✅ | Do nothing |
| Clean disconnect | ❌ | ❌ | Relaunch |
| Lich crashed hard (stale session file) | ✅ | ❌ | Relaunch |
| Lich starting up (process before session) | ❌ | ✅ | Relaunch* |
Note
The startup race window is small; with a 10-minute schedule it's very unlikely to trigger a duplicate launch. If you see duplicate launches in the log, that's the cause.
This is the single most important thing to get right on macOS.
Ruby's Dir.tmpdir resolves to whatever $TMPDIR is set to. macOS gives each user session its own per-user, per-session temp directory under /var/folders/..., looking something like:
/var/folders/zz/abc123xyz/T/
So Lich's session file ends up at, e.g.:
/var/folders/zz/abc123xyz/T/simutronics/sessions/Mycharname.session
The problem: the $TMPDIR your interactive Terminal sees, the $TMPDIR your GUI Lich sees, and the $TMPDIR a launchd-spawned process sees may not all be the same. Under modern launchd they usually are (per-user temp), but it's worth verifying.
With Lich running for your character, run this in the same Terminal you'd normally launch from:
ls -la "${TMPDIR}simutronics/sessions/" 2>/dev/null
# or
find /var/folders -name "*.session" 2>/dev/null | grep simutronicsWhatever directory the .session file actually lives in — that's your LICH_SESSION_DIR. Set it explicitly in the LaunchAgent (instructions below), don't trust the script to guess.
Save this as ~/bin/lich-watchdog.sh (or ~/.local/bin/lich-watchdog.sh) and make it executable.
#!/usr/bin/env bash
# lich-watchdog.sh (macOS)
# Ensures a specific Lich character session is running. Launches if not.
# Considers the character "running" only if BOTH are true:
# 1. Lich's session file exists at $LICH_SESSION_DIR/<Char>.session
# 2. A ruby process is alive whose command line references lich + --login <Char>
set -u
# ---- Config ----------------------------------------------------------------
CHARACTER_NAME="${CHARACTER_NAME:-Mycharname}"
RUBY_BIN="${RUBY_BIN:-/usr/bin/ruby}"
LICH_DIR="${LICH_DIR:-$HOME/lich5}"
LICH_SCRIPT="${LICH_SCRIPT:-lich.rbw}"
# Pass launch args as a single string; respects shell quoting.
LICH_ARGS="${LICH_ARGS:---login $CHARACTER_NAME --gemstone --stormfront}"
LOG_FILE="${LOG_FILE:-$HOME/Library/Logs/lich-watchdog/watchdog.log}"
# Session dir — on macOS this MUST be set explicitly to where Lich actually
# writes (see wiki, "macOS tempdir gotcha"). Default uses $TMPDIR which may
# or may not match what Lich sees from its own launch context.
LICH_SESSION_DIR="${LICH_SESSION_DIR:-${TMPDIR}simutronics/sessions}"
SESSION_FILE="$LICH_SESSION_DIR/${CHARACTER_NAME}.session"
# ---------------------------------------------------------------------------
log() {
local stamp
stamp="$(date '+%Y-%m-%d %H:%M:%S')"
local dir
dir="$(dirname -- "$LOG_FILE")"
mkdir -p -- "$dir" 2>/dev/null || true
printf '[%s] %s\n' "$stamp" "$*" >> "$LOG_FILE" 2>/dev/null || true
}
test_session_file() {
[[ -f "$SESSION_FILE" ]]
}
test_lich_process() {
# Match any ruby process whose command line references lich AND
# --login <Character>. Case-insensitive; tolerates "--login Name" or
# "--login=Name". pgrep -f matches against the full command line.
local escaped
escaped="$(printf '%s' "$CHARACTER_NAME" | sed 's/[][\\.^$*+?(){}|/]/\\&/g')"
local pattern="lich.*--login[[:space:]=]+${escaped}([[:space:]]|\$)"
pgrep -fi -- "$pattern" >/dev/null 2>&1
}
main() {
local session_ok=0 process_ok=0
test_session_file && session_ok=1
test_lich_process && process_ok=1
if (( session_ok == 1 && process_ok == 1 )); then
log "OK: $CHARACTER_NAME running (session file + process both present)."
exit 0
fi
local reasons=()
(( session_ok == 0 )) && reasons+=("no session file")
(( process_ok == 0 )) && reasons+=("no matching ruby process")
local IFS=', '
log "Not running for $CHARACTER_NAME (${reasons[*]}). Launching..."
if [[ ! -x "$RUBY_BIN" ]]; then
log "ERROR: ruby not executable at $RUBY_BIN"; exit 2
fi
if [[ ! -f "$LICH_DIR/$LICH_SCRIPT" ]]; then
log "ERROR: $LICH_SCRIPT not found in $LICH_DIR"; exit 2
fi
# Launch detached so the watchdog exit doesn't take Lich with it.
( cd "$LICH_DIR" && \
nohup "$RUBY_BIN" "$LICH_SCRIPT" $LICH_ARGS \
>/dev/null 2>&1 < /dev/null & )
log "Launched: $RUBY_BIN $LICH_SCRIPT $LICH_ARGS (cwd=$LICH_DIR)"
exit 0
}
main "$@"Make it executable:
chmod +x ~/bin/lich-watchdog.shWarning
Don't use /usr/bin/ruby blindly — the system Ruby on macOS is old (2.6) and deprecated. Use the Ruby you actually run Lich with: probably rbenv which ruby, asdf which ruby, or /opt/homebrew/bin/ruby (Apple Silicon) / /usr/local/bin/ruby (Intel). Confirm with which ruby from the same shell you'd normally launch Lich from.
The script reads configuration from environment variables, with defaults. Set these in the LaunchAgent plist (recommended — keeps the script generic) rather than editing the script.
| Variable | Description | Default |
|---|---|---|
CHARACTER_NAME |
Character name as it appears in --login
|
Mycharname |
RUBY_BIN |
Full path to ruby executable |
/usr/bin/ruby (override this) |
LICH_DIR |
Lich install directory | $HOME/lich5 |
LICH_SCRIPT |
Lich entry script | lich.rbw |
LICH_ARGS |
Launch arguments (must match how you normally launch) | --login $CHARACTER_NAME --gemstone --stormfront |
LOG_FILE |
Where to write watchdog activity | $HOME/Library/Logs/lich-watchdog/watchdog.log |
LICH_SESSION_DIR |
Where Lich writes session files | ${TMPDIR}simutronics/sessions |
Before scheduling, launch Lich the normal way for your character, then run:
ps -eo pid,args | grep -i lich | grep -v grepLook at the actual command line. The regex lich.*--login[[:space:]=]+<name> expects something like:
- ✅
ruby lich.rbw --login Mycharname --gemstone --stormfront - ✅
ruby lich.rbw --login=Mycharname ... - ❌
ruby lich.rbw --login "Mycharname" ...(quoted name won't match) If your real command quotes the name, change the pattern intest_lich_processfrom--login[[:space:]=]+${escaped}to--login[[:space:]=]+"?${escaped}"?.
~/Library/LaunchAgents/com.elanthia.lich-watchdog.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.elanthia.lich-watchdog</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOUR_USERNAME/bin/lich-watchdog.sh</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>CHARACTER_NAME</key>
<string>Mycharname</string>
<key>RUBY_BIN</key>
<string>/opt/homebrew/bin/ruby</string>
<key>LICH_DIR</key>
<string>/Users/YOUR_USERNAME/lich5</string>
<key>LICH_SCRIPT</key>
<string>lich.rbw</string>
<key>LICH_ARGS</key>
<string>--login Mycharname --gemstone --stormfront</string>
<key>LICH_SESSION_DIR</key>
<string>/var/folders/zz/REPLACE_WITH_REAL_PATH/T/simutronics/sessions</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StartInterval</key>
<integer>600</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/YOUR_USERNAME/Library/Logs/lich-watchdog/launchd.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USERNAME/Library/Logs/lich-watchdog/launchd.err.log</string>
</dict>
</plist>Important
Replace YOUR_USERNAME with your macOS username throughout — launchd plists don't expand ~ or $HOME. Replace LICH_SESSION_DIR with the actual path you discovered earlier (find /var/folders -name "*.session" 2>/dev/null | grep simutronics).
Key things to know about this plist:
-
StartIntervalis in seconds.600= 10 minutes. -
RunAtLoadfires the agent once immediately after loading, so you don't have to wait 10 minutes for the first run. -
StandardOutPath/StandardErrorPathcapture anything the script writes to stdout/stderr (separate from yourLOG_FILE); useful for debugging launchd-side issues.
launchctl load -w ~/Library/LaunchAgents/com.elanthia.lich-watchdog.plistThe -w flag persists the load across logout/reboot.
launchctl list | grep lich-watchdogYou should see something like:
- 0 com.elanthia.lich-watchdog
The middle column is the last exit status (0 = clean), the first is the current PID (- if not currently running).
launchctl kickstart -k gui/$(id -u)/com.elanthia.lich-watchdogThen check your log:
tail -f ~/Library/Logs/lich-watchdog/watchdog.logIf you change the plist, unload and reload:
launchctl unload ~/Library/LaunchAgents/com.elanthia.lich-watchdog.plist
launchctl load -w ~/Library/LaunchAgents/com.elanthia.lich-watchdog.plistBefore trusting the schedule, verify all four scenarios behave correctly. Run the script manually:
~/bin/lich-watchdog.shThen check ~/Library/Logs/lich-watchdog/watchdog.log.
| # | Setup | Expected log line | Expected behavior |
|---|---|---|---|
| 1 | Lich already running for the character | OK: ... both present |
No relaunch |
| 2 | Lich cleanly exited (no process, no session file) | Not running (no session file, no matching ruby process) |
Relaunch |
| 3 | Kill the ruby process (kill <pid>, stale session file remains) |
Not running (no matching ruby process) |
Relaunch |
| 4 | Delete the session file manually but leave process running | Not running (no session file) |
Relaunch* |
Warning
Scenario 4 will cause a duplicate launch, which is the expected tradeoff for catching scenario 3.
Watchdog runs but Lich window doesn't appear
The launched ruby process probably can't reach your `WindowServer` because launchd spawned it without the right session context. Two things to check:- Confirm the agent is a LaunchAgent (in
~/Library/LaunchAgents/), not a LaunchDaemon (/Library/LaunchDaemons/). Agents run in your user GUI session; Daemons don't. - Make sure you're using
gui/$(id -u)/...when poking it withlaunchctl kickstart, notuser/$(id -u)/...— the GUI domain is what grants access to the windowing system.
Watchdog always relaunches even though Lich is running
Almost always a `LICH_SESSION_DIR` mismatch on macOS.Run while Lich is up:
find /var/folders -name "Mycharname.session" 2>/dev/nullCompare the directory to what your plist has for LICH_SESSION_DIR. If different, update the plist, unload/reload the agent.
Less commonly: the process regex doesn't match. Confirm with:
ps -eo pid,args | grep -i lich | grep -v grep"Operation not permitted" or "couldn't load plist"
Modern macOS sandboxes things aggressively. Most common causes:-
File permissions: the plist needs to be owned by you and have mode 644.
chmod 644 ~/Library/LaunchAgents/com.elanthia.lich-watchdog.plist. - TCC / Full Disk Access: Terminal (or whatever app loads the agent) may need Full Disk Access in System Settings → Privacy & Security if Lich lives in a sandboxed location.
- Code signing: if you got an Apple gatekeeper popup about the ruby binary, run Lich manually once and clear it.
launchd fires but script exits immediately with no log
Check the launchd-level logs from your plist: ```bash cat ~/Library/Logs/lich-watchdog/launchd.err.log ```Common culprits: ProgramArguments path wrong, script not executable, RUBY_BIN path wrong.
If still empty, run the script manually from a non-Terminal context to simulate launchd:
env -i HOME="$HOME" PATH=/usr/bin:/bin ~/bin/lich-watchdog.shMultiple Lich instances spawn
Either (a) the regex isn't matching your real command line so it thinks nothing is running, or (b) you hit the startup race window where the process was alive but the session file wasn't written yet. The log tells you which: look for "no matching ruby process" (case a) vs "no session file" (case b).$TMPDIR resolved correctly in Terminal but plist sees a different path
This is the tempdir gotcha biting you. Don't rely on `$TMPDIR` resolution from the plist — hardcode the actual `/var/folders/.../T/simutronics/sessions` path in `LICH_SESSION_DIR`. Macs are usually consistent per-user, so it shouldn't change unless you log out and back in or the system resets per-session temp.Make a copy of the plist per character with a unique Label and unique LICH_ARGS/CHARACTER_NAME:
-
~/Library/LaunchAgents/com.elanthia.lich-watchdog.charA.plist→Label=com.elanthia.lich-watchdog.charA -
~/Library/LaunchAgents/com.elanthia.lich-watchdog.charB.plist→Label=com.elanthia.lich-watchdog.charBLoad each separately:
launchctl load -w ~/Library/LaunchAgents/com.elanthia.lich-watchdog.charA.plist
launchctl load -w ~/Library/LaunchAgents/com.elanthia.lich-watchdog.charB.plistThe process regex matches on the specific character name, so they won't interfere with each other. Use separate StandardOutPath/StandardErrorPath per plist so launchd logs don't get mixed up.