Running 24/7 — the LaunchAgent + the standalone Mac app.
Pre-market alerts fire at 4am AZ. The scanner fires at 07:00 ET. Earnings drops at 4:30pm. None of those help if the engine is only running when you have a Terminal window open. v7.8 separates the brain from the window: a macOS LaunchAgent runs control_server (+ audit watcher + index fetcher) 24/7 starting at every login, with auto-respawn on crash. The dashboard window — opened via Safari "Add to Dock" or Chrome "Install" — becomes optional. This lesson covers the LaunchAgent architecture, the install/uninstall flow, and why the wrapper choice (Safari vs Tauri vs Chrome) is orthogonal to the 24/7 question.
The two halves are independent
"Standalone Mac app" actually means two separate concerns most users conflate:
- The window — what you look at. Safari Add-to-Dock, Chrome Install, or a regular browser tab. Visible UI surface.
- The brain — what runs in the background.
control_server.py,audit_framework.py --watch,fetch_indexes.py --watch. Invisible, but the only thing that has to run for alerts to fire.
Pre-v7.8, the two were tangled: closing the Terminal window that ran control_server killed the brain too. v7.8 separates them. The LaunchAgent owns the brain; the wrapper owns the window. Either can change without affecting the other.
The LaunchAgent install
One command, no sudo:
./packaging/launchd/install.sh
The script:
- Kills whatever's on :8001 (your manually-started server, if any), so the LaunchAgent can take over the port cleanly.
- Detects your Python — framework
/Library/Frameworks/Python.framework/Versions/3.14/bin/python3preferred, then Homebrew, then systempython3. - Substitutes paths into a checked-in plist template via
sed—@PYTHON@,@REPO@,@LOGDIR@become real values. - Validates the result with
plutil -lintso a bad substitution gets caught before launchd sees a broken plist. - Writes the plist to
~/Library/LaunchAgents/com.swingdeck.control-server.plist. launchctl load -wthe plist — control_server starts immediately.- Smoke-checks port :8001 is bound within 10 seconds.
The script is idempotent. Run it twice and it unloads the existing LaunchAgent before reloading the latest template. Re-installs after a repo move just work.
The plist itself
| Key | Value | Why |
|---|---|---|
Label | com.swingdeck.control-server | Stable identifier for launchctl list / unload |
ProgramArguments | [python3, control_server.py] | The actual command |
WorkingDirectory | repo root | So os.path relative reads resolve |
RunAtLoad | true | Start on login + on first install |
KeepAlive.Crashed | true | Respawn on crash |
KeepAlive.SuccessfulExit | false | Don't respawn on SIGTERM (so the user can launchctl unload cleanly) |
ThrottleInterval | 10 | Don't burn CPU in a crash loop |
EnvironmentVariables.AUTO_SHUTDOWN_ON_CLOSE | false | Critical — disables the "no dashboard heartbeat → server exits" watchdog |
EnvironmentVariables.PATH | framework + Homebrew + system | Subprocess spawns find what they need; launchd's default PATH is minimal |
StandardOutPath / StandardErrorPath | ~/Library/Logs/SwingDeck/launchd.{out,err}.log | Crash tracebacks land here even before control_server's own logger initializes |
ProcessType | Interactive | No CPU deprioritization; audit cycles need full thread |
The two settings that matter most operationally: AUTO_SHUTDOWN_ON_CLOSE=false (without it, the server exits 5 minutes after the last dashboard window closes — fine for desk use, fatal for 24/7) and KeepAlive.SuccessfulExit=false (so SIGTERM is a clean stop rather than a fight-the-LaunchAgent ritual).
Verify it's working
# Status + last exit code (0 = healthy, non-zero = recent crash)
launchctl list com.swingdeck.control-server
# Live tail control_server's own log
tail -f ~/Library/Logs/SwingDeck/server.log
# Live tail launchd-captured stdio (catches crashes BEFORE control_server's logger initializes)
tail -f ~/Library/Logs/SwingDeck/launchd.err.log
# Confirm :8001 is bound
lsof -iTCP:8001 -sTCP:LISTEN -nP
Uninstall
./packaging/launchd/uninstall.sh
Unloads the LaunchAgent, removes the plist, kills any lingering process on :8001. Logs in ~/Library/Logs/SwingDeck/ are left in place as operator trail.
The standalone-window half
With the brain running 24/7, you open the window when you want to look. Safari → File → Add to Dock or Chrome → ⋮ → Install Swing Deck both create a Dock icon that opens a standalone window: no URL bar, no tabs, native macOS traffic lights, system notifications attributed to "Swing Deck." Closing the window doesn't touch the brain — the LaunchAgent keeps going. Re-opening the window the next morning shows the brain's current state, not a cold-start.
The wrapper choice is genuinely orthogonal to the LaunchAgent: a regular Chrome browser tab works identically to the standalone Dock window from the brain's perspective. The standalone window is a UX upgrade for the user, not a functional requirement for the 24/7 architecture. (Separate lesson 41 on log discipline — and the standalone-app blog post — cover the wrapper trade-offs in more depth.)
The real lesson
A discipline product whose discipline only runs when the user is actively watching has a structural gap. The 24/7 LaunchAgent closes it. The brain runs whether you remember to start it or not, whether you're at your desk or not, whether the window is open or not. The viewing surface becomes optional; the engine never sleeps. That separation — brain owned by the OS, window owned by the user — is what v7.8's standalone-app shape is really about.
Related: L37 — Portfolio Truth · L41 — reading server logs · browser tab → Mac app pivot post