At 12:04 on August 23, 2026, something quietly rewrote my outreach DM schedule down to three runs a day — 2:31 AM, 10:31 AM, and 6:31 PM. For the several days it took me to notice, that cost me dozens of missed opportunities every single day. I still don't know what did it.
Why this mechanism works
The problem: you can't tell when automation has stopped
When you strip away the details, making money as a solo developer comes down to one equation: automation uptime = revenue. I was a college student making 100k yen a month, then 600k a month juggling multiple gigs, then back to zero when I was laid off for company reasons — and the reason I could build a Claude Code autonomous environment in six months and get to 1.2M yen in monthly revenue is that I had an outreach DM lane running while I slept.
Automated outreach DMs are made up of four launchd jobs. Instagram, Threads, follower prospecting, and YouTube each have their own independent plist and Python script. These control their start times via StartCalendarInterval. For example, com.lily.outreach-ig runs eight times at 8:20, 10:20, 12:20, 14:20, 16:20, 18:20, 20:20, and 22:20, and com.lily.outreach-th runs seven times every two hours from 9:38 to 21:38 — sends happen during daytime hours.
The problem is that a launchd plist is just an XML file, and anyone (or anything) can rewrite it. macOS does not notify the user when a plist changes. Even when launchctl list shows a job as "running," the schedule itself may be operating on post-rewrite values. That is exactly what happened on August 23: the job was alive. It was just running on a completely different setting — "three times a day, in the middle of the night."
What tipped me off was looking at the DM send-count log manually one evening. The noon and 4 PM slots kept showing zero, so I dug in and found that StartCalendarInterval inside com.lily.outreach-ig's plist had been reduced to just three entries: 2:31 / 10:31 / 18:31. At the same time, com.lily.outreach-th had been cut from seven runs to three. The mtime (last modified time) was 2026-08-23 12:04.
What I learned from this is that "running" and "running correctly" are two different things. What you need to monitor is not whether the process is alive, but the configuration values inside the plist themselves.
The value of designing for "record," not just "restore"
If all you want is a simple integrity check, you could implement it with cron or Watch Paths. But what I insisted on this time was a design that leaves a forensic log at the same moment it restores.
There are two reasons. The first is reproducibility. If the same rewrite happens repeatedly, a snapshot of which processes were running just before it gives me a way to narrow down the suspects. The second is the peace of mind of being able to trace why it changed after the fact. An automation environment is complex, and I can't rule out that I rewrote it myself by mistake from some script. With logs, I can separate "my own mistake" from "outside interference."
When outreach-schedule-guard.sh detects something, it does four things: record the rewrite along with the mtime, capture a list of recently running processes, copy the plist as a timestamped backup, and restore the correct values and make launchd reload them. Of these, the forensic part takes up close to half of the script's 50 lines. I could have taken the "it just needs to work" route and only done the restore, but I didn't want to give up on finding the cause.
The structural weak point in a launchd plist
Once you understand the structure of StartCalendarInterval, you see why reading it with Python's plistlib is the best approach.
launchd plists are stored in XML format (or in binary format via plutil -convert binary1). Taking com.shun.self-repair.plist as an example, it has an array structure like this:
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>20</integer>
</dict>
<dict>
<key>Hour</key>
<integer>13</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
<dict>
<key>Hour</key>
<integer>19</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
</array>
The key point is that Hour and Minute are stored as integer types. Parsing XML with grep or awk in a shell script gives you no type guarantees, and it doesn't work at all against binary plists. If you read the plist with python3 -c "import plistlib", you can handle both text and binary formats through the same API, and you can reliably extract Hour as an int.
Rewrite detection is just a string comparison between this read value and the expected value from the SPECS array. The expected value is generated in the form "8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20", and if it doesn't match the value pulled out of the actual plist, an alert fires.
The overall flow
Architecture overview
定期実行(launchd job)
│
▼
outreach-schedule-guard.sh
│
├─ SPECS配列から期待スケジュールを生成
│ com.lily.outreach-ig → 8,10,12,14,16,18,20,22 時 :20
│ com.lily.outreach-th → 9,11,13,15,17,19,21 時 :38
│ com.lily.followers-outreach → 8,10,12,14,16,18,20,22 時 :35
│ com.lily.outreach-yt → 10,12,14,16,18,20 時 :52
│
├─ python3 plistlib で各 plist の StartCalendarInterval を実読み取り
│
├─ [一致] → 処理なし・終了
│
└─ [不一致] ─────────────────────────────────────────┐
│
① forensicログ出力 │
- plist の mtime を記録 │
- 現在値 vs 期待値を並べて記録 │
- ps で python/node/bash/launchctl/plutil │
の直近プロセスをキャプチャ(最大25件) │
│
② バックアップ作成 │
$label.plist.bak-guard-YYYYmmdd-HHMMSS │
│
③ python3 plistlib で正しい値に書き戻し │
d['StartCalendarInterval'] = [ │
{'Hour': int(h), 'Minute': minute} │
for h in hours.split(',') │
] │
│
④ plutil -lint で整合性検証 │
OK → launchctl bootout → sleep 1 │
→ launchctl bootstrap → 復元ログ │
NG → "🔴 復元後のplistが壊れている"ログ │
→ 手動対応に委ねる │
Core logic: the plist reading part
The script processes the four jobs in order with a shell for loop. The first thing each iteration does is read the plist through a python3 heredoc and print a CSV of Hour:Minute to standard output.
actual="$(python3 - "$plist" <<'PY'
import plistlib, sys
try:
with open(sys.argv[1],'rb') as f: d = plistlib.load(f)
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
print(','.join(f"{r.get('Hour')}:{r.get('Minute')}" for r in rows))
except Exception as e:
print('ERR')
PY
)"
The reason for opening in binary mode with open(sys.argv[1], 'rb') is that plistlib.load() auto-detects whether the plist is XML or binary. rows = d.get('StartCalendarInterval') or [] guards against None when the entry doesn't exist. And the isinstance(rows, dict) check is defensive handling so things don't break when StartCalendarInterval is written as a single <dict> rather than an array (the case where only one time is registered).
The resulting actual becomes a string like "8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20".
Core logic: generating and comparing expected values
Each entry in the SPECS array is defined in a custom "label:minute:hours" format.
SPECS=(
"com.lily.outreach-ig:20:8,10,12,14,16,18,20,22"
"com.lily.outreach-th:38:9,11,13,15,17,19,21"
"com.lily.followers-outreach:35:8,10,12,14,16,18,20,22"
"com.lily.outreach-yt:52:10,12,14,16,18,20"
)
From there, label, minute, and hours are split apart with shell parameter expansion, and expected is assembled.
label="${spec%%:*}"; rest="${spec#*:}"
minute="${rest%%:*}"; hours="${rest#*:}"
Next, the hours are split into an array with IFS=',', and a CSV in "Hour:Minute" format is generated. For outreach-ig, expected becomes "8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20".
The single line [ "$actual" = "$expected" ] && continue is the heart of the check. If they match, that job is skipped and we move to the next. The moment there's a mismatch, the forensic recording and restore sequence kicks off.
Core logic: the restore part
Writing back is done with a python3 heredoc, same as reading.
python3 - "$plist" "$minute" "$hours" <<'PY'
import plistlib, sys
path, minute, hours = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with open(path,'rb') as f: d = plistlib.load(f)
d['StartCalendarInterval'] = [{'Hour': int(h), 'Minute': minute} for h in hours.split(',')]
with open(path,'wb') as f: plistlib.dump(d, f)
PY
The reason minute is received via sys.argv[2] and cast with int() is that the value coming from the SPECS string is a str. plistlib.dump(d, f) writes in XML format by default (fmt=plistlib.FMT_XML), which has the side effect of converting an originally binary plist to XML. That said, launchd reads XML-format plists without any problem, so it doesn't matter in practice.
After writing back, the XML structure is validated with plutil -lint "$plist". Feeding a broken plist to launchd causes unpredictable behavior, so this gate can't be skipped. Only when validation passes do we re-register with launchd in the following order:
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
sleep 1
launchctl bootstrap "gui/$(id -u)" "$plist"
The reason for putting sleep 1 between bootout and bootstrap is to give launchd a grace period to update its internal state. If you omit it, there are cases where bootstrap returns an error. The failure of bootout is ignored with 2>/dev/null because even if it was already unloaded, there's no problem as long as bootstrap goes through.
Implementing the forensic log
Three kinds of information are recorded the instant a rewrite is detected.
log "$label: 書き換え検知 mtime=$(stat -f '%Sm' -t '%F %T' "$plist")"
log "$label: 現在 = $actual"
log "$label: あるべき= $expected"
ps -Ao pid,lstart,comm | tail -n +2 | \
grep -iE "python|node|bash|launchctl|plutil" | tail -25 | \
while read -r l; do log "$label: ps> $l"; done
stat -f '%Sm' -t '%F %T' is macOS-specific formatting that gets the plist's last modified time in YYYY-MM-DD HH:MM:SS format. This is the only physical evidence of when it was rewritten.
The ps filter targets python|node|bash|launchctl|plutil because the means of rewriting a plist are roughly limited to those commands. tail -25 caps it at 25 lines so the log doesn't balloon.
In the actual incident at 12:04 on August 23, this forensic log would have let me narrow down the suspect processes. Ironically, the guard script itself was built after the incident, so there's no ps snapshot from that time. The "I wish I'd built this earlier" regret is exactly what drove the design of this forensic recording mechanism.
Implementation details
set -uo pipefail — why I dropped -e
The top of the script is set -uo pipefail. There's no -e.
set -uo pipefail
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
I originally wrote it as -euo pipefail, since "exit immediately on error" is supposed to be best practice. But then a bug showed up where the script died abruptly in the forensic ps output section. Here's the cause:
ps -Ao pid,lstart,comm | tail -n +2 | while read -r p rest2; do echo "$p $rest2"; done \
| grep -iE "python|node|bash|launchctl|plutil" | tail -25 \
| while read -r l; do log "$label: ps> $l"; done
grep -iE "python|node|bash|..." returns exit code 1 when there are zero matching lines. Under set -e, a non-zero exit code means immediate exit — so the bug was that the script died in the perfectly normal state where not a single suspicious process was running. The correct behavior for the forensic part is "output nothing if there's nothing," but -e turned that into "die if there's nothing."
By dropping -e and keeping only pipefail, I can still detect unintended silent failures (errors partway through a pipe) while letting an empty grep continue as-is.
Explicit PATH and the log function
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
LOG="$HOME/.claude/logs/outreach-schedule-guard.log"
launchd jobs do not inherit the user's shell environment. Even if PATH looks fine from Terminal, /opt/homebrew/bin may not exist in the shell launchd starts. Since python3 is installed via Homebrew, without an explicit PATH the job silently dies with python3: command not found right after launch. From this experience, I've made it a habit to always override PATH at the top of any shell script meant for launchd.
The log function is printf-based.
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
I originally wrote it as echo "$(date '+%F %T') $*" >> "$LOG". But echo's behavior changes depending on the shell implementation and whether the -e option is present. When the string being appended to a log message contains \n, the output differs depending on whether echo expands it. printf cleanly separates the format string from the values, so line breaks and whitespace in log output are identical in every environment.
Guarding against a missing plist
[ -f "$plist" ] || { log "$label: plist が無い"; continue; }
If a label defined in the SPECS array doesn't actually have a plist, python3's open() raises an error and the script stops. The design is to run an existence check, leave a log, then move on to the next job. Having "plist missing" in the log lets me later determine whether it was unloaded on the launchd side or the file itself was deleted.
Naming the backups
The current plist is always copied before restoring.
cp "$plist" "$plist.bak-guard-$(date +%Y%m%d-%H%M%S)"
That leaves a file like com.lily.outreach-ig.plist.bak-guard-20260823-120404. The reason for burning the timestamp into the filename is that when the same plist gets rewritten multiple times, I can tell which point in time each one is from with nothing but ls. Using .bak-guard-<datetime> instead of .bak as the extension is also deliberate — it gives the file a name that's unlikely to be recognized as a plist, so launchd doesn't load it by mistake.
For now, I haven't added automatic deletion of backups. If the same plist gets rewritten many times in a short period, the .bak files pile up — but that pile has value in itself as evidence that "the rewrites are recurring," so deletion stays manual.
What the self-repair job's plist teaches you
com.shun.self-repair.plist is the plist for the launchd job that periodically starts the outreach-schedule-guard script itself. Reading this plist directly, there are several values worth referencing as background-job design.
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
<key>RunAtLoad</key>
<false/>
LowPriorityIO: true lowers IO priority so it doesn't get in the way of other processes' disk access. The guard script only reads/writes plists and writes logs, so low IO priority is fine.
Nice: 10 lowers CPU priority by 10 steps. 0 is the default, and higher values mean lower priority. It would defeat the purpose if the monitoring job hogged CPU and slowed down the main outreach DM scripts, so I lower it explicitly.
ProcessType: Background means it's classified under the "Background" category in macOS Activity Monitor. Combined with LowPriorityIO, it becomes subject to the OS's battery and CPU optimizations.
RunAtLoad: false disables immediate execution right after loading. launchd has a feature that triggers a job once the moment it's loaded; with true, it runs the instant you launchctl bootstrap. The guard script only needs to run on schedule via StartCalendarInterval, so I've turned off the unnecessary immediate run.
StartCalendarInterval is three times a day: 9:20, 13:30, and 19:30. Outreach DMs run from 8 AM to 10 PM, yet the guard only runs three times, because rewrites don't happen continuously — the pattern is "everything gets rewritten at once and goes unnoticed for a while." Three times a day is enough to detect it, and since the check itself reads/writes plists, I want to minimize that.
Where I got stuck
Snag ①: the python heredoc broke from variable expansion
The first code I wrote used <<PY instead of <<'PY'.
# NG: クォートなしヒアドキュメント
actual="$(python3 - "$plist" <<PY
import plistlib, sys
...
PY
)"
An unquoted <<PY makes the shell expand variables inside the heredoc contents. Even if there isn't a single $() or $variable in the Python code, there are cases where, for example, the [1] in sys.argv[1] becomes a target for glob expansion in a zsh environment. In practice [1] wasn't expanded, but on another line the { and } inside f"{r.get('Hour')}:{r.get('Minute')}" were interpreted by the shell and produced a syntax error.
Wrapping it in single quotes as <<'PY' passes the heredoc contents to the Python interpreter as-is. Ever since, whenever I pass python/ruby/node code from Bash via a heredoc, I always use the <<'EOXX' form.
Snag ②: StartCalendarInterval returns a dict when there's a single entry
When you read a plist with plistlib, the value of StartCalendarInterval is normally a list (a Python array). But when the plist has a <dict> written directly without an <array> tag, plistlib returns a dict.
<!-- 1時刻だけ登録したplistの例 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>10</integer>
<key>Minute</key><integer>0</integer>
</dict>
At first I had rows = d.get('StartCalendarInterval') or [] and looped with for r in rows. Iterating a dict yields the string keys 'Hour' and 'Minute', so r.get('Hour') becomes a get on a string and returns None. The output became "None:None", the comparison naturally didn't match, and the restore process ran every single time — an endless loop.
# 修正後
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
The fix was the one line where the isinstance(rows, dict) check wraps a single dict in a list. Until I added it, restore logs kept appearing every time I verified behavior with a test plist that had only one time set — I initially thought it was a logic bug and spent hours searching in completely the wrong places.
Snag ③: not knowing ${expected:+,} and getting a leading comma every time
Here's the code I first wrote for generating the expected CSV.
expected=""
for h in "${harr[@]}"; do expected="${expected},${h}:${minute}"; done
For com.lily.outreach-ig, once the loop ran, expected became ",8:20,10:20,12:20...". There's a comma at the front. Naturally it never matched actual ("8:20,10:20,..."). Mismatch every time meant restore every time — the guard script itself was rewriting the plist on every run.
Bash's ${var:+value} parameter expansion means "expand value if var is non-empty, empty string if it's empty."
for h in "${harr[@]}"; do expected="${expected}${expected:+,}${h}:${minute}"; done
With ${expected:+,}, the behavior becomes "insert a comma first only when there's already something there." On the first element expected is empty so no comma is inserted, and from the second onward a , goes in. The result is the correct CSV: "8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20".
I knew Bash parameter expansions like :- (default value) and := (assignment) well, but :+ (expand only when non-empty) was something I'd never been conscious of until I hit this bug.
Snag ④: launchctl bootstrap returned an error immediately
The first version of the restore process called bootstrap right after bootout.
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
launchctl bootstrap "gui/$(id -u)" "$plist" # sleep なし
Run in that order, bootstrap failed with Load failed: 5: Input/output error. After receiving bootout, launchd completes the job's termination processing asynchronously and internally. Even when bootout returns 0, hitting bootstrap at a moment when launchd's internal state hasn't been cleared yet causes a race and an error.
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
sleep 1
if launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null; then
log "$label: 復元して再読込した"
else
log "$label: 🔴 bootstrap に失敗した(手動確認が要る)"
fi
Inserting sleep 1 resolved it. "Wait one second" isn't an elegant solution, but launchd's documentation says nothing about guarantees around asynchronous completion, and I confirmed empirically that one second is a sufficient safety margin, so I adopted it rather than spending more time investigating.
In the field, "it doesn't work for some reason" → "it worked after I added sleep 1" tends to be the kind of fix where you move on without the reason ever becoming clear. Here, leaving 🔴 bootstrap に失敗した(手動確認が要る) in the log builds a safety net so I'd notice immediately if it failed again.
Snag ⑤: why rest2 was needed in read -r p rest2 in the ps pipe
The forensic ps handling actually didn't work when I first tried to write it simply.
# 最初のナイーブな実装
ps -Ao pid,lstart,comm | grep -iE "python|node|bash|launchctl|plutil" | tail -25
This looked like it worked, but because bash is included in the grep, the bash process handling the pipe itself matched the grep and got mixed into the output. Also, since ps output column widths vary by environment, reading fields with a while loop using only read -r l pulled trailing whitespace into the variable and made the log messy.
The actual code is a two-stage pipe.
ps -Ao pid,lstart,comm | tail -n +2 | while read -r p rest2; do echo "$p $rest2"; done \
| grep -iE "python|node|bash|launchctl|plutil" | tail -25 \
| while read -r l; do log "$label: ps> $l"; done
The rest2 in while read -r p rest2 looks unused, but it's important. With read -r p rest2, p gets the first field (PID) and rest2 gets all remaining fields. Re-emitting with echo "$p $rest2" afterwards produces a formatted line, excluding the header row (already removed with tail -n +2). If you receive whole lines with just read -r l, the extra spaces from ps's formatting are preserved; by decomposing into p rest2 and reassembling, runs of spaces are normalized to a single space.
As a result, the ps> lines in the forensic log come out in a readable form like 12345 Fri Aug 23 12:04:11 2026 python3. If this format had been in place when the August 23 incident happened, the very first thing I could have done was cross-check that pid, execution time, and command name.
In the next part, we'll look at the procedure for registering this guard script itself with launchd, along with real examples of the log output from when the guard actually fired (when I deliberately rewrote a plist to test it).
Gotchas
In addition to the five snags listed in part 2, here's a roundup of problems I actually ran into while building this. Bullet points, comprehensively.
① self-repair.plist's ProgramArguments didn't call the script directly
When I first read com.shun.self-repair.plist, ProgramArguments wasn't what I expected.
<array>
<string>~/.claude/scripts/claude-quota-guard.py</string>
<string>--job</string>
<string>com.shun.self-repair</string>
<string>--</string>
<string>/bin/bash</string>
<string>~/.claude/scripts/self-repair.sh</string>
</array>
Rather than calling /bin/bash self-repair.sh directly, it goes through claude-quota-guard.py. At first I didn't understand "why the extra layer," and I was testing by naively writing /bin/bash outreach-schedule-guard.sh directly into ProgramArguments. It's a design that anticipates the guard script itself having processing that calls the Claude API (including future extensions) and places it under the umbrella of quota management. I later established a rule that all guard-type jobs go through this wrapper.
② The job shows as "running" in launchctl list but the PID stays -
Right after launchctl bootstrap succeeded, I checked launchctl list | grep com.shun.self-repair and the PID was - with status 0. I panicked — "did the load fail?" — but this is normal behavior. With RunAtLoad: false and scheduled execution via StartCalendarInterval, launchd just registers the job and doesn't start it until the next scheduled time. A PID of - means "waiting." Only after one of 9:20, 13:30, or 19:30 has passed and you check launchctl list again does a PID value appear. The correct verification procedure is not to check liveness by PID, but to watch the log file with tail -f after the scheduled time.
③ $HOME comes out empty unless you specify HOME in EnvironmentVariables
Before I put an EnvironmentVariables block in the plist, I hit a bug where the path to $HOME/.claude/logs/ inside self-repair.sh became an empty string. launchd only partially inherits the GUI session's user environment variables, and there are cases where a shell script starts with HOME undefined.
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/実ユーザー名</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
<key>PATH</key>
<string>/Users/実ユーザー名/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...</string>
</dict>
Since ~ expansion doesn't work inside a plist, HOME has to be written as an absolute path. If you're calling scripts that use Node.js via nvm, PATH also needs to include ~/.nvm/versions/node/vXX.X.X/bin. Forget it and node: command not found piles up silently in the StandardErrorPath log.
④ The job won't start if the StandardOutPath / StandardErrorPath directory doesn't exist
I once wrote the following into a plist without creating the directory first.
<key>StandardErrorPath</key>
<string>~/.claude/logs/self-repair.launchd.err.log</string>
<key>StandardOutPath</key>
<string>~/.claude/logs/self-repair.launchd.log</string>
If you launchctl bootstrap while ~/.claude/logs/ doesn't exist, the job loads successfully (it even shows up in launchctl list), yet the script never runs at the scheduled time. Since the error log can't be written, nothing is left behind — just the result "it isn't running." The fix is to run mkdir -p first, and to build the mkdir -p "$(dirname "$LOG")" pattern from the top of outreach-schedule-guard.sh into the setup script that runs before plist registration too.
⑤ plistlib.dump() converts binary plists to XML
In the write-back processing, I call plistlib.dump(d, f) with default arguments. The default is fmt=plistlib.FMT_XML, so even if the original plist was in binary format (converted with plutil -convert binary1), it becomes XML format after write-back.
with open(path,'wb') as f: plistlib.dump(d, f)
# ↑ デフォルトはXML形式で出力。バイナリ維持したい場合は:
with open(path,'wb') as f: plistlib.dump(d, f, fmt=plistlib.FMT_XML)
# または
with open(path,'wb') as f: plistlib.dump(d, f, fmt=plistlib.FMT_BINARY)
launchd reads both XML and binary formats without issue, so there's no actual harm, but the format changes when you inspect the contents with plutil -p. I once got confused comparing against a backup and thinking "did something rewrite this?" If you want to preserve the original format, you need to detect whether it's binary at read time and branch the output format accordingly. This time I settled on "as long as launchd can read it, standardize on XML."
⑥ launchd mistakenly reads .bak backup files as jobs
If there's a file with the .plist extension in the LaunchAgents directory, macOS's launchd management features recognize it as a job candidate. In an early version where I gave backup files a .plist extension, I named them com.lily.outreach-ig.bak.plist instead of com.lily.outreach-ig.plist.bak, which caused confusion when launchd GUI tools (LaunchControl and the like) displayed them as jobs. The current naming convention $label.plist.bak-guard-YYYYmmdd-HHMMSS deliberately ensures the name doesn't end in .plist.
⑦ Including bash in the forensic ps filter makes it match itself
grep -iE "python|node|bash|launchctl|plutil"
Because this pattern includes bash, the bash process handling the pipe matches the grep itself. A line like /bin/bash /path/to/outreach-schedule-guard.sh always appears in the log. That's noise, but I've deliberately left it in — the very fact that "the guard script's own start time is recorded in ps" can serve as evidence. That said, when looking at a ps snapshot, it's nearly certain that the "bash" line is the guard script itself.
⑧ The gap between the guard's run frequency and the DM send frequency
com.shun.self-repair.plist's StartCalendarInterval is three times: 9:20, 13:30, 19:30. Meanwhile, for outreach DMs, com.lily.outreach-ig runs eight times every two hours from 8:20 to 22:20. The math works out to: "if the guard last ran at 19:30 and a rewrite happens at 20:00, the next detection takes 13 hours, until 9:20 the next morning."
I'm aware of this design flaw. As countermeasures, I'm considering either raising the guard's run frequency (say, hourly) or building a self-consistency check into the outreach DM scripts themselves at startup. For now I've kept it to three runs on the premise that "rewrites don't recur frequently," trading off against the side effects of reading/writing plists. Since the August 23 incident did real damage in the form of "went unnoticed for several days," this judgment may be revisited in the future.
⑨ The stat -f '%Sm' format is macOS-specific
The stat -f '%Sm' -t '%F %T' used to get the mtime in the forensic log is macOS stat command syntax. On GNU Linux, stat -c '%y' is the equivalent, but this script is macOS-only so it's not a problem. That said, if a colleague on another Mac asks "does this script work on Linux too?", I need to tell them the stat part won't. If you wanted to make it cross-platform, one option is to unify on os.path.getmtime() on the Python side.
Best practices
Here are the decision criteria I picked up through building and operating this.
1. Override PATH at the top of launchd shell scripts
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
Nearly every case of "works in the terminal but gives command not found from launchd" comes down to PATH. Node under nvm and Homebrew's python3 don't exist in the system PATH. This is always line one of my launchd script template.
2. Drop set -e and keep only pipefail
For monitoring and restore scripts, -e is all harm and no benefit. On top of the reason explained in part 2 (the script dies on an empty grep with exit code 1), a launchctl bootout failure (when it's already unloaded) is also fatal under -e. Keeping just pipefail still lets you detect silent errors partway through a pipe.
3. Read and write plists with python3 plistlib
XML parsing with shell grep/awk has no type guarantees and can't handle binary plists at all. python3 can auto-detect both XML and binary formats with the standard library's plistlib, and reliably extract Hour and Minute as integers. Since python3 -c "import plistlib" is complete in one line, there are zero external dependencies.
4. Always quote heredocs in the <<'EOF' form
When passing Python code from Bash via a heredoc, an unquoted <<EOF breaks things because the shell tries to variable-expand the Python code. Python code containing {, }, or $ is especially dangerous. Making <<'PY' with single quotes a habit from the start prevents bugs that are very hard to debug.
5. Always create a timestamped backup before restoring
cp "$plist" "$plist.bak-guard-$(date +%Y%m%d-%H%M%S)"
If the restore process malfunctions (for example, a mistake in the SPECS array causes correct settings to be judged as a "mismatch" forever), you can't get back to the original settings without a backup. The reason for burning in the timestamp is that when multiple rewrites occur, you can follow the chronology with nothing but ls -lt.
6. Validate plist structure with plutil -lint before handing it to launchd
If you feed launchd a plist whose structure broke during write-back, the job silently stops starting.
if plutil -lint "$plist" >/dev/null 2>&1; then
# launchctl bootstrap へ進む
else
log "🔴 復元後のplistが壊れている(戻していない)"
fi
With this gate in place, you eliminate the risk of polluting launchd with half-written XML from a plistlib bug or an interrupted write.
7. Put sleep 1 between bootout and bootstrap
launchd's bootout completes asynchronously. Even when bootout returns 0, hitting bootstrap at a moment when launchd's internal job-termination processing hasn't finished returns Load failed: 5: Input/output error. One second of waiting is empirically a sufficient safety margin. Further, catching bootstrap's success/failure in an if statement and logging it means you'd notice immediately if it failed again.
8. Always leave a forensic log (mtime + ps snapshot)
An implementation that only restores and calls it a day buries "what happened" in the dark. Recording the plist's mtime at the moment a rewrite is detected, plus a list of running processes, gives you clues for finding the cause. The regret from the August 23 incident — "with a guard script I could have identified the suspect process" — is the motivation for this design. As long as you have the logs, patterns emerge when the same rewrite recurs.
9. Write a printf-based log function
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
echo's handling of newlines varies with the shell implementation and the presence of the -e flag. When a string containing backslashes slips into a log message, echo may or may not expand \n into a newline depending on the environment. printf's separation of format string and values guarantees consistency.
10. Add a guard for a missing plist
[ -f "$plist" ] || { log "$label: plist が無い"; continue; }
If a plist for a label defined in the SPECS array has disappeared, python3's open() raises an exception and the whole script stops. Making it an existence check + log + continue to the next loop means that even if one of the four jobs is missing its plist, the other three still get checked normally.
11. Use RunAtLoad: false to prevent immediate execution right after registration
launchd has a feature that starts a job exactly once the moment it's loaded with launchctl bootstrap (equivalent to RunAtLoad: true). The guard script only needs to run on schedule, so RunAtLoad: false turns off the unnecessary immediate run. The restore process is idempotent, but reading/writing plists has a non-zero cost.
12. Minimize the monitoring job's resource impact with LowPriorityIO and Nice: 10
com.shun.self-repair.plist has the following configured.
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
It defeats the purpose if the monitoring job eats CPU and IO and degrades the performance of the outreach DM scripts it's monitoring. Nice: 10 lowers CPU priority by 10 steps (0 is the default), and LowPriorityIO lowers IO priority. Combined with ProcessType: Background, it becomes subject to the OS's battery and CPU optimizations.
13. Guard the StartCalendarInterval single-entry problem with an isinstance check
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
Without this one line, the restore process runs every time against a plist with only one time set (a <dict> written directly without an <array> tag). Whether plistlib returns a dict or a list depends on the XML structure, so you need to check defensively.
14. Solve the leading-comma CSV problem with ${var:+,}
for h in "${harr[@]}"; do expected="${expected}${expected:+,}${h}:${minute}"; done
${expected:+,} is a Bash parameter expansion meaning "insert a comma if expected is non-empty, do nothing if empty." Writing expected="${expected},${h}:${minute}" puts a comma at the front, producing an infinite restore loop where expected and actual values never match.
15. Always register the guard itself with launchd via bootout → sleep 1 → bootstrap
When updating an existing job (when you change ProgramArguments or StartCalendarInterval), running bootstrap without launchctl unload or bootout doesn't apply the changes. Bundling the following procedure into a setup script means never having to second-guess it.
launchctl bootout "gui/$(id -u)/com.shun.self-repair" 2>/dev/null
sleep 1
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.shun.self-repair.plist
launchctl list | grep self-repair
In that final launchctl list check, a PID of - with status 0 means it registered successfully.
Wrapping up
This mechanism, which began with the schedule rewrite at 12:04 on August 23, 2026, ended up being a step toward "an environment where automation uptime doesn't need human eyes to guarantee it." Whether the four outreach DM lanes are running on the correct schedule — 29 runs a day in total — is something outreach-schedule-guard.sh checks three times a day at 9:20, 13:30, and 19:30, and automatically restores if anything looks off, without me having to check.
The core implementation is 73 lines. Read with plistlib, compare against expected values, and if they differ, back up, restore, and re-register with launchd. Leave a forensic log along the way. That's it. Even launchd's seemingly complex internals can be controlled from a shell script, as long as you have python3's standard library to read and write the XML directly.
The reason I sustain 1.2M yen in monthly revenue as a solo developer is that I've stacked up "mechanisms that keep running autonomously during the hours no human is watching." Each individual mechanism is simple, but because a stoppage translates directly into lost revenue, I design them to repair themselves when they stop. The guard script in this article is one example of that philosophy.
What's the longest one of your automations has been quietly broken before you noticed?
I've written up the full picture of the system, the breakdown of the 1.2M yen, and the 30-day procedure in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)