| name | launchd-watchpaths-service-reload |
| description | Set up a macOS launchd LaunchAgent that watches specific files and restarts a long-running service when any of them change. Closes the 'service runs stale code' failure mode for daemons that load config or code only at startup — an edit does nothing until the process restarts. Self-contained: one plist + one shell script per watched service. Trigger keywords: launchd WatchPaths, watch file restart service, hot reload daemon, auto-restart on file change, stale config running, kickstart on edit, file watcher launchd. |
| license | MIT |
| metadata | {"version":"1.1.1","hermes":{"tags":["macOS","Launchd","Automation","Hot-Reload","Services"]}} |
Launchd WatchPaths Service Reload
Auto-restart a long-running launchd service when one of its source files changes on disk.
Eliminates the "I edited the file, the daemon kept doing the old thing" failure that comes
from services loading config or code only at process startup.
Pick the right tool
- Time-based restart (nightly reload) → a
StartCalendarInterval LaunchAgent, not this
- File-change restart for a single service → this skill
- Many services sharing watched files → one watcher PER service; a single watcher that
kickstarts several services amplifies every burst of file changes into a restart storm
Files
Two files per watched service:
<your-config-repo>/launchd/ai.example.watch-<name>.plist
<your-config-repo>/scripts/reload-<name>-on-change.sh
Symlink the plist into ~/Library/LaunchAgents/; keep the script in a repo so it syncs
across machines.
Plist template
launchd does NOT expand $HOME or ~ inside plist <string> values. The $HOME
placeholders below are for portability across users — substitute the literal expansion
(run echo $HOME) before writing the plist to disk. The shell script (next section) runs
under a real shell and DOES expand $HOME natively.
<?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>ai.example.watch-<NAME></string>
<key>ProgramArguments</key>
<array>
<string>$HOME/<your-config-repo>/scripts/reload-<NAME>-on-change.sh</string>
</array>
<key>WatchPaths</key>
<array>
<string>$HOME/<path-to-watched-file-A></string>
<string>$HOME/<path-to-watched-file-B></string>
</array>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>$HOME/Library/Logs/watch-<NAME>.log</string>
<key>StandardErrorPath</key>
<string>$HOME/Library/Logs/watch-<NAME>.log</string>
</dict>
</plist>
Key choices:
WatchPaths lists individual files, not directories. The service loads specific files;
sibling-file changes are noise. Per-file watching reduces false triggers.
ThrottleInterval: 30 debounces bursts (a git pull can touch several watched files at
once; you don't want 5 restarts in 5 seconds).
RunAtLoad: false — only fire on actual file changes, not every reboot. The tradeoff:
changes that happen while the watcher is NOT loaded never fire — before first bootstrap,
while the machine is off, or while the agent is unloaded during an edit. launchd does not
re-trigger for past changes, so the target can run stale code indefinitely with the watcher
"installed". Reconcile at every attach point: kickstart the target once right after
bootstrap (Install step 4 below), and again any time you bootout/re-bootstrap the watcher.
- Stdout + stderr go to one log so you have a single audit trail of when the watcher fired.
Script template
#!/bin/sh
LOG=$HOME/Library/Logs/watch-<NAME>.log
TS=$(/bin/date '+%Y-%m-%d %H:%M:%S')
echo "[$TS] watched file changed; kickstarting <TARGET-SERVICE-LABEL>" >>"$LOG"
if /bin/launchctl kickstart -k "gui/$(/usr/bin/id -u)/<TARGET-SERVICE-LABEL>" 2>>"$LOG"; then
echo "[$TS] kickstart ok" >>"$LOG"
else
echo "[$TS] kickstart FAILED (exit $?) — wrong label, or target not bootstrapped?" >>"$LOG"
fi
Log the kickstart result — a typo'd or never-bootstrapped target label otherwise fails
invisibly on every fire while runs increments and the log looks healthy.
Use absolute paths to /bin/launchctl and /usr/bin/id — launchd's default PATH is minimal.
Install
chmod +x ~/<your-config-repo>/scripts/reload-<NAME>-on-change.sh
ln -s ~/<your-config-repo>/launchd/ai.example.watch-<NAME>.plist \
~/Library/LaunchAgents/ai.example.watch-<NAME>.plist
launchctl bootstrap gui/$(id -u) \
~/Library/LaunchAgents/ai.example.watch-<NAME>.plist
launchctl kickstart -k gui/$(id -u)/<TARGET-SERVICE-LABEL>
Before step 3, substitute every placeholder in one pass and verify none remain:
sed -i '' "s|\$HOME|$HOME|g" ~/<your-config-repo>/launchd/ai.example.watch-<NAME>.plist
grep -n '\$HOME\|<[A-Za-z-]*>' ~/<your-config-repo>/launchd/ai.example.watch-<NAME>.plist \
&& echo "UNSUBSTITUTED PLACEHOLDERS REMAIN — fix before bootstrap" || echo "plist clean"
A plist with a literal $HOME bootstraps without error and then never fires — the
verification below cannot distinguish it from a healthy idle watcher.
Update / Uninstall
launchd reads the plist at bootstrap; edits require a reload (and another reconcile kickstart):
launchctl bootout gui/$(id -u)/ai.example.watch-<NAME>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.example.watch-<NAME>.plist
launchctl kickstart -k gui/$(id -u)/<TARGET-SERVICE-LABEL>
Verification
After install:
launchctl print gui/$(id -u)/ai.example.watch-<NAME>
Expected output highlights:
state = not running — correct idle state for a WatchPaths-only LaunchAgent (it is
WAITING for triggers, not running continuously)
runs = 0 — no firings yet
- An
event triggers block with com.apple.launchd.WatchPaths listed
Live test (touch a watched file and confirm the target service's PID changes):
launchctl print gui/$(id -u)/<TARGET-SERVICE-LABEL> | awk -F'= ' '/pid =/ {print $2}'
touch ~/<path-to-watched-file-A>
sleep 12
launchctl print gui/$(id -u)/<TARGET-SERVICE-LABEL> | awk -F'= ' '/pid =/ {print $2}'
If the PID didn't change: tail the log to see whether the watcher fired. If it fired but the
service didn't restart, the kickstart command is the issue (check launchctl kickstart exit
status and the service label).
Caveats
- One watcher per service. Don't fan one watcher out to many services — a burst of file
changes becomes a synchronized restart storm.
git checkout / git pull --rebase fires the watcher. Even a transient touch counts.
ThrottleInterval debounces, but expect ~one restart per pull that changes a watched file.
- Symlinks: watch the real file, not the link. Symlink-resolution behavior for
WatchPaths is inconsistent across macOS versions
[Unverified either way] — pointing
WatchPaths at the resolved real path sidesteps the question entirely.
- The plist is machine-specific once substituted. The script syncs across machines via
the repo, but the plist carries a literal home path — each machine substitutes its own
copy before bootstrap; don't expect a committed, substituted plist to work elsewhere.
- Logs grow unbounded. The script appends forever; add the log to newsyslog/logrotate or
truncate it periodically.