Skip to content

Lich Character Watchdog ‐ macOS

Ryan P. McKinnon edited this page May 20, 2026 · 2 revisions

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:

Table of Contents

How it works

The watchdog considers a character "running" only if both of these are true:

  1. Session file present — Lich writes <tmpdir>/simutronics/sessions/<CharacterName>.session while connected. On macOS, <tmpdir> is not /tmp — see the next section.
  2. Matching process alive — A ruby process exists whose command line references lich and --login <CharacterName>. If either signal is missing, the watchdog launches Lich with the configured arguments.

Why both checks?

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.

The macOS tempdir gotcha

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.

Find the actual path

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 simutronics

Whatever 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.

The script

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.sh

Warning

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.

Configuration

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

Verifying the regex matches your real launch

Before scheduling, launch Lich the normal way for your character, then run:

ps -eo pid,args | grep -i lich | grep -v grep

Look 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 in test_lich_process from --login[[:space:]=]+${escaped} to --login[[:space:]=]+"?${escaped}"?.

launchd setup

1. Create the LaunchAgent plist

~/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:

  • StartInterval is in seconds. 600 = 10 minutes.
  • RunAtLoad fires the agent once immediately after loading, so you don't have to wait 10 minutes for the first run.
  • StandardOutPath/StandardErrorPath capture anything the script writes to stdout/stderr (separate from your LOG_FILE); useful for debugging launchd-side issues.

2. Load the agent

launchctl load -w ~/Library/LaunchAgents/com.elanthia.lich-watchdog.plist

The -w flag persists the load across logout/reboot.

3. Verify it's loaded

launchctl list | grep lich-watchdog

You 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).

4. Trigger an immediate run

launchctl kickstart -k gui/$(id -u)/com.elanthia.lich-watchdog

Then check your log:

tail -f ~/Library/Logs/lich-watchdog/watchdog.log

Reloading after edits

If 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.plist

Testing

Before trusting the schedule, verify all four scenarios behave correctly. Run the script manually:

~/bin/lich-watchdog.sh

Then 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.

Troubleshooting

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:
  1. Confirm the agent is a LaunchAgent (in ~/Library/LaunchAgents/), not a LaunchDaemon (/Library/LaunchDaemons/). Agents run in your user GUI session; Daemons don't.
  2. Make sure you're using gui/$(id -u)/... when poking it with launchctl kickstart, not user/$(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/null

Compare 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.sh
Multiple 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.
## Running multiple characters

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.plistLabel = com.elanthia.lich-watchdog.charA
  • ~/Library/LaunchAgents/com.elanthia.lich-watchdog.charB.plistLabel = com.elanthia.lich-watchdog.charB Load each separately:
launchctl load -w ~/Library/LaunchAgents/com.elanthia.lich-watchdog.charA.plist
launchctl load -w ~/Library/LaunchAgents/com.elanthia.lich-watchdog.charB.plist

The 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.

Clone this wiki locally