My Outreach Schedule Got Rewritten to 2:31 AM Without Me: Building a launchd Guard That Self-Heals in Under 10 Minutes A developer who built an autonomous Claude Code sales setup generating ¥1.2M a month reported that their macOS LaunchAgents were rewritten to send outreach DMs at 2:31 AM, risking account flags. They created a self-healing guard that detects and reverts unauthorized plist changes within 10 minutes, emphasizing robustness over speed for solo automation. Six months after a layoff left me with zero take-home pay, I had built an autonomous Claude Code setup that now brings in a steady ¥1.2M a month. The whole thing rests on one assumption: outbound sales automation that runs at midnight, on weekends, without me touching it. I took "never stops" for granted right up until the early hours of August 23, 2026. When solo developers want to grow revenue, most jump straight into "working faster." Send DMs quicker, process bigger lists, write copy with a higher reply rate. All of that matters. But somewhere past ¥600K a month, the bottleneck stopped being the speed of the work and became the robustness of the environment . My outreach sales DMs is fully automated by four LaunchAgents. | Agent | Sends per day | Schedule | |---|---|---| | com.lily.outreach-ig | 8 | 8:20–22:20 every 2 hours | | com.lily.outreach-th | 7 | 9:38–21:38 every 2 hours | | com.lily.followers-outreach | 8 | 8:35–22:35 every 2 hours | | com.lily.outreach-yt | 6 | 10:52–20:52 every 2 hours | That's 29 runs a day, firing automatically every time the Mac is up, with no input from me. As long as this runs normally, I can focus on content production. The problem: these configuration files plists got rewritten from outside . That was a real incident. It's right there in the comment at the top of the script. php 2026-08-23 12:04 に outreach-ig が 8回/日 - 3回/日 2:31/10:31/18:31 、 outreach-th が 7回/日 - 3回/日 に一斉に書き換えられ、 深夜帯に営業DMを送る設定になっていた。 Translation: at 12:04 on 2026-08-23, outreach-ig was changed from 8 runs/day to 3 runs/day at 2:31/10:31/18:31, and outreach-th from 7 runs/day to 3 runs/day, all at once, leaving the setup sending sales DMs in the middle of the night. It wasn't just that 8 runs got cut to 3. The times had shifted to include a 2:31 AM slot. An account that keeps sending DMs in the dead of night gets flagged by platform spam detection. Worst case, the account is frozen, and the sales lists and followers I'd built up are gone. I never identified the culprit. Maybe some script I'd installed at the time, maybe a side effect of a system update. But the important thing isn't finding the culprit. It's building a mechanism that detects the change immediately and reverts it . "Fix it when I notice" is over. The machine fixes it before a human notices. outreach-ig's normal schedule is 8 runs a day. Drop that to 3 and daily touchpoints fall by 62.5%. Factoring in my DM-to-meeting conversion rate, leaving the schedule broken for just half a day wipes out dozens of meeting opportunities on a monthly basis. On top of that, when a 2:31 AM send is detected, the risk of the account being flagged isn't a vague "that seems bad." It's a concrete risk spelled out in each platform's API terms of service. Repeated unintended late-night sends drag down the account's reputation score in short order. So instead of "fix it later," I designed the solution as "automatically revert within 10 minutes." ┌───────────────────────────────────────────────────────────┐ │ macOS launchd │ │ │ │ ┌─────────────────────┐ StartCalendarInterval │ │ │ outreach-ig.plist │ 8:20, 10:20, 12:20 ... ──────►│ browser-slot.sh │ │ outreach-th.plist │ 9:38, 11:38, 13:38 ... ──────►│ run-lane.sh │ │ followers-outreach │ 8:35, 10:35, 12:35 ... ──────►│ (営業DM送信) │ │ outreach-yt.plist │ 10:52, 12:52, 14:52 ... ──────►│ │ └─────────────────────┘ │ │ ▲ 書き換え検知 → 即時復元 │ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ outreach-schedule-guard.plist │ │ │ │ StartInterval: 600(10分ごと) │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ outreach-schedule-guard.sh │ │ │ │ 1. plist を python3+plistlib で読む │ │ │ │ 2. SPECS の期待値と実値を比較 │ │ │ │ 3. 差異あり → forensicログ + 復元 + reload│ │ │ └─────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────┘ In the diagram: "営業DM送信" = sends sales DMs; "書き換え検知 → 即時復元" = tamper detected → immediate restore; "10分ごと" = every 10 minutes; steps 1–3 = read the plist with python3+plistlib, compare expected values in SPECS against actual values, and on mismatch write a forensic log, restore, and reload. The watchdog guard is itself driven by its own plist com.lily.outreach-schedule-guard.plist . By handing even the "watchdog that watches the watchdog" to launchd, I delegate the script's own stop/restart/crash resilience to the OS layer. The core of outreach-schedule-guard.sh is that the monitored targets are declared as a string array of label:minute:hour-list . 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" Read com.lily.outreach-ig:20:8,10,12,14,16,18,20,22 as "outreach-ig's StartCalendarInterval should be 8 entries at minute 20 with Hour=8,10,12,14,16,18,20,22." To add a new outreach lane, you add one line to the SPECS array and it's monitored. To read StartCalendarInterval from the XML plist, I chose Python's standard library plistlib over /usr/libexec/PlistBuddy or hand-rolled XML parsing, and for a reason. python 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 " plistlib.load handles both binary and XML plists. StartCalendarInterval comes back as a dict when there's a single entry and a list when there are several, so isinstance rows, dict absorbs that. The output is a comma-separated string like 8:20,10:20,12:20,... . The expected value is generated in the same format on the bash side. expected="" IFS=',' read -r -a harr <<< "$hours" for h in "${harr @ }"; do expected="${expected}${expected:+,}${h}:${minute}"; done ${expected:+,} is the idiom for "prepend a comma only if expected is non-empty." It produces 8:20,10:20,... , and a plain string comparison against actual is all the diff detection needs. "$actual" = "$expected" && continue If they match, continue skips. If they differ, the next block runs. Step 1: forensic log log "$label: 書き換え検知 mtime=$ stat -f '%Sm' -t '%F %T' "$plist" " log "$label: 現在 = $actual" log "$label: あるべき= $expected" 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 stat -f '%Sm' records the plist's last-modified time. The ps command logs up to 25 processes matching python, node, bash, launchctl, or plutil. Even if it doesn't identify the culprit, a timestamp plus a process list is useful for investigation later. Step 2: backup cp "$plist" "$plist.bak-guard-$ date +%Y%m%d-%H%M%S " The tampered plist is saved under a name like com.lily.outreach-ig.plist.bak-guard-20260823-120412 . This preserves evidence so the pre-restore state can be reproduced afterward. Step 3: overwrite with correct values via plistlib python 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 Other keys such as Label , ProgramArguments , and EnvironmentVariables are left untouched. Only StartCalendarInterval is replaced. Because it's written out with plistlib.dump, the generated XML conforms to Apple's official format. Step 4: validate and reload if plutil -lint "$plist" /dev/null 2 &1; then 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 else log "$label: 🔴 復元後のplistが壊れている(戻していない)" fi plutil -lint confirms the file is valid before launchctl bootout → bootstrap . bootout is safe to call even against an agent that isn't loaded, so there's no need to pre-check whether the label is registered. If bootstrap fails, the log entry gets a 🔴 and explicitly says "manual check required." The design doesn't hide situations it can't resolve on its own. Log strings: "復元して再読込した" = restored and reloaded; "bootstrap に失敗した(手動確認が要る)" = bootstrap failed, manual check required; "復元後のplistが壊れている(戻していない)" = restored plist is broken, not reloaded.