diff --git a/Brewfile b/Brewfile
index a896c50..3f447d1 100644
--- a/Brewfile
+++ b/Brewfile
@@ -70,6 +70,12 @@ cask "steam"
cask "transmission" # BitTorrent client
cask "whatsapp"
+# Gateway-only (mac-mini): same hostname match as is_gateway_host in bootstrap.sh
+require "socket"
+if Socket.gethostname.split(".").first.match?(/\Amac-mini(-\d+)?\z/)
+ cask "jellyfin" # Media server (http://localhost:8096)
+end
+
# Casks - Fonts
cask "font-jetbrains-mono-nerd-font"
diff --git a/CLAUDE.md b/CLAUDE.md
index d7dcfa9..8b80aac 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -116,10 +116,11 @@ Files in `home/` are symlinked to `~` by `bootstrap.sh`. This allows version con
| agent-session-analytics | 8081 | `/agent-session-analytics` | `com.evansenter.agent-session-analytics` |
| agent-memory-store | 8083 | `/agent-memory-store` | `com.evansenter.agent-memory-store` |
| obsidian-mcp | 3010 | `/obsidian-mcp` | `com.evansenter.obsidian-mcp` |
+| jellyfin | 8096 | — | `com.evansenter.jellyfin` |
LaunchAgent plists live in `~/Library/LaunchAgents/`. The external service repos (event-bus, session-analytics, memory-store) have their own `make install-server`; obsidian-mcp is set up by *this* repo's bootstrap (`claude mcp add` + plist in `LaunchAgents/`, wrapper in `home/.bin/obsidian-mcp-start`, requires Obsidian with the Local REST API plugin running). Reload with `launchctl unload` + `launchctl load`.
-Note: `agent-memory-store` is documented infra but is **not** provisioned by bootstrap (no `claude mcp add`, no `AGENT_MEMORY_STORE_URL` in `settings.local.json`) — it's registered out-of-band where used. The repo-managed LaunchAgents are: `com.evansenter.obsidian-mcp` (gateway only), `com.evansenter.sysload` (zellij CPU/RAM widget, if zellij installed), and `com.user.cargo-sweep` (periodic `cargo sweep`, if cargo installed).
+Note: `agent-memory-store` is documented infra but is **not** provisioned by bootstrap (no `claude mcp add`, no `AGENT_MEMORY_STORE_URL` in `settings.local.json`) — it's registered out-of-band where used. The repo-managed LaunchAgents are: `com.evansenter.obsidian-mcp` (gateway only), `com.evansenter.jellyfin` (gateway only, runs the server binary from the `jellyfin` cask headless with `DOTNET_USE_POLLING_FILE_WATCHER=1` — on macOS 27 its FSEvents config watcher blocks startup forever; don't also launch Jellyfin.app, which would fight it for port 8096), `com.evansenter.sysload` (zellij CPU/RAM widget, if zellij installed), and `com.user.cargo-sweep` (periodic `cargo sweep`, if cargo installed).
**Adding new LaunchAgents:** Plists go in the top-level `LaunchAgents/` directory (NOT `home/Library/LaunchAgents/`). Use `__HOME__` as a placeholder for the user's home directory — `install_launch_agent()` in bootstrap does `sed` substitution at install time. Logs go to `~/.local/log/`. Add an `install_launch_agent` call in `install_launch_agents()`, gated on the binary being present.
diff --git a/LaunchAgents/com.evansenter.jellyfin.plist b/LaunchAgents/com.evansenter.jellyfin.plist
new file mode 100644
index 0000000..9559371
--- /dev/null
+++ b/LaunchAgents/com.evansenter.jellyfin.plist
@@ -0,0 +1,53 @@
+
+
+
+
+ Label
+ com.evansenter.jellyfin
+
+
+ ProgramArguments
+
+ /Applications/Jellyfin.app/Contents/MacOS/jellyfin
+ --webdir
+ /Applications/Jellyfin.app/Contents/Resources/jellyfin-web
+ --ffmpeg
+ /Applications/Jellyfin.app/Contents/MacOS/ffmpeg
+ --datadir
+ __HOME__/Library/Application Support/jellyfin
+
+
+
+ EnvironmentVariables
+
+ DOTNET_USE_POLLING_FILE_WATCHER
+ 1
+
+
+ RunAtLoad
+
+
+ KeepAlive
+
+ SuccessfulExit
+
+
+
+ ThrottleInterval
+ 30
+
+
+ StandardOutPath
+ /dev/null
+
+ StandardErrorPath
+ __HOME__/.local/log/jellyfin.err
+
+
+ ProcessType
+ Standard
+
+
diff --git a/bootstrap.sh b/bootstrap.sh
index a06c436..750902b 100755
--- a/bootstrap.sh
+++ b/bootstrap.sh
@@ -393,6 +393,11 @@ install_launch_agents() {
install_launch_agent "com.evansenter.obsidian-mcp.plist"
fi
+ # Install Jellyfin server LaunchAgent (gateway host, from the jellyfin cask)
+ if is_gateway_host && [[ -x /Applications/Jellyfin.app/Contents/MacOS/jellyfin ]]; then
+ install_launch_agent "com.evansenter.jellyfin.plist"
+ fi
+
# Install sysload writer LaunchAgent (only if zellij is installed)
# Caches `top` output to ~/.cache/sysload every 10s so the zjstatus
# sysload widget can read it without spawning `top` per-tab-per-redraw.
diff --git a/tests/test-bootstrap.sh b/tests/test-bootstrap.sh
index 061ca40..a11e63a 100755
--- a/tests/test-bootstrap.sh
+++ b/tests/test-bootstrap.sh
@@ -704,6 +704,12 @@ test_launch_agent_substitutes_home() {
return $ok
}
+test_jellyfin_agent_uses_polling_file_watcher() {
+ # Without it the server hangs before logging or binding 8096 on macOS 27
+ grep -A1 'DOTNET_USE_POLLING_FILE_WATCHER' "$SCRIPT_DIR/../LaunchAgents/com.evansenter.jellyfin.plist" \
+ | grep -q '1'
+}
+
test_launch_agent_idempotent_on_unchanged_plist() {
_load_install_launch_agent
local root home stub; root=$(mktemp -d); home=$(mktemp -d); stub=$(mktemp -d)
@@ -1030,6 +1036,7 @@ main() {
run_test "sysload-writer tolerates top failure" "test_sysload_writer_tolerates_top_failure"
run_test "launch agent substitutes __HOME__ and reloads" "test_launch_agent_substitutes_home"
run_test "launch agent idempotent on unchanged plist" "test_launch_agent_idempotent_on_unchanged_plist"
+ run_test "jellyfin agent uses polling file watcher" "test_jellyfin_agent_uses_polling_file_watcher"
echo ""
echo "=== INSTALL_AI & INSTALL_TAILSCALE gating ==="