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.
(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.
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
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.)
<key>StartInterval</key>
<integer>600</integer>
StartInterval
in com.lily.outreach-schedule-guard.plist
is 600 seconds, i.e. 10 minutes. launchd's StartInterval keeps counting time during system sleep, and on wake it detects "how many runs should have happened" and fires. So even if the tampering occurs during the three hours the Mac was asleep, the automatic restore runs within 10 minutes of waking.
Log output is split into two files, ~/.claude/logs/outreach-schedule-guard.out.log
and outreach-schedule-guard.err.log
. Normal logs go to stdout
, unexpected bash errors to stderr
, so there's no mixed noise when monitoring in real time with tail -f
.
-e
is dropped from set -uo pipefail
Line one of the script contains a quiet but important decision.
set -uo pipefail
There's no -e
(exit immediately on error). That's deliberate. With -euo pipefail
, the whole script dies the moment launchctl bootout
returns non-zero.
bootout
returns an error when the specified label isn't registered with launchd. Since the four agents are checked in sequence inside a for loop, a failed bootout
on the first one would skip monitoring of the remaining three. That's not a watchdog. So I keep -u
(error on undefined variables) and pipefail
(catch failures mid-pipe) but drop -e
. The bootout
call also gets 2>/dev/null
to silence the "not registered" message on stderr.
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
Scripts started by launchd inherit nothing from your login shell's ~/.zshrc
or ~/.zprofile
. However carefully you've grown PATH
in the terminal, none of it reaches the launchd execution environment. If python3
can't be found, every step that uses plistlib goes silent.
The same PATH is defined in guard.plist's EnvironmentVariables
block.
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
The monitored outreach-ig.plist, by contrast, has a somewhat longer PATH.
<key>BROWSER_SLOT_TIMEOUT_SEC</key>
<string>2700</string>
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...</string>
It has BROWSER_SLOT_TIMEOUT_SEC
(45-minute session cap) and the nvm Node.js path, because outreach-ig drives a browser via Playwright. The guard only uses python3
, stat
, launchctl
, and plutil
, so Homebrew plus the system PATH is enough. The "minimum required" PATH differs between watcher and watched.
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
printf
instead of echo
because echo
interprets flags like -e
depending on the shell implementation. date '+%F %T'
produces a timestamp in the form 2026-08-23 13:07:37
.
$LOG
is a file path defined inside the script (~/.claude/logs/outreach-schedule-guard.log
) and appended to directly. It's separate from the StandardOutPath and StandardErrorPath defined in guard.plist.
<key>StandardOutPath</key>
<string>~/.claude/logs/outreach-schedule-guard.out.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/outreach-schedule-guard.err.log</string>
The result is three log files with distinct roles. ** .log** is the monitoring output the guard writes on purpose.
.out.log
.err.log
tail -f ~/.claude/logs/outreach-schedule-guard.err.log
is empty, you know instantly that there are zero bash-level problems. Mixing monitoring logs and shell errors in one file means parsing which noise is which every single time.Here is the log the guard wrote for outreach-th during the 2026-08-23 incident, verbatim.
2026-08-23 13:07:37 com.lily.outreach-th: 書き換え検知 mtime=2026-08-23 13:07:37
2026-08-23 13:07:37 com.lily.outreach-th: 現在 = 2:31,10:31,18:31
2026-08-23 13:07:37 com.lily.outreach-th: あるべき= 9:38,11:38,13:38,15:38,17:38,19:38,21:38
2026-08-23 13:07:37 com.lily.outreach-th: ps> 80406 日 8/23 12:50:01 2026 Python
2026-08-23 13:07:37 com.lily.outreach-th: ps> 80842 金 8/21 11:06:32 2026 node
2026-08-23 13:07:37 com.lily.outreach-th: ps> 81332 日 8/23 12:50:08 2026 bash
2026-08-23 13:07:37 com.lily.outreach-th: ps> 82665 日 8/23 12:50:32 2026 playwright/driver/node
(以下、合計25件)
2026-08-23 13:07:38 com.lily.outreach-th: 復元して再読込した
(Line labels: "書き換え検知" = tamper detected; "現在" = current; "あるべき" = expected; "(以下、合計25件)" = 25 entries in total; "復元して再読込した" = restored and reloaded.)
A few facts can be read from this.
** mtime=2026-08-23 13:07:37**: the same second as the guard's own execution timestamp. Either the guard ran right after the plist was modified, or the modification happened just before the 10-minute poll. Either way, the numbers confirm detection within 10 minutes.
The Playwright process in ps (PID 82665): a Playwright driver started at 12:50:32 was still around. That overlaps with the plist's modification window. Not conclusive evidence, but it supports the hypothesis that "the plist changed during some Playwright run."
"Restored and reloaded" at 13:07:38: less than one second from detection to restore. Everything, including re-registration with launchd, completes in real time.
plutil -lint
and sleep 1
are necessary
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
is Apple's official plist validator. plistlib.dump()
generally produces valid XML, but an interruption mid-write or an unexpected filesystem error leaving a half-written byte sequence is not a zero-probability event. Bootstrapping a corrupt plist can put launchd in an unexpected state, so right after Python writes the file, it always goes through Apple's validation before re-registration.
sleep 1
is an explicit wait for launchd's processing. The bootout
command sends launchd a request to unload the label, but launchd completes that work asynchronously. Call bootstrap
immediately and launchd may decide it's "still registered" and fail.
Looking again at the guard's own plist, four keys are set explicitly.
<key>RunAtLoad</key>
<false/>
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
** RunAtLoad: false**: don't run immediately at bootstrap time (e.g. login). Set to true, the guard would run on every startup. The fixed
StartInterval: 600
is sufficient, so the extra run is skipped.** LowPriorityIO: true and Nice: 10**: lower I/O priority and add +10 to CPU nice. The guard is a lightweight process that runs once every 10 minutes for a few dozen milliseconds. If it interfered with the I/O or browser rendering of outreach-ig, which is actually sending the sales DMs, that would defeat the purpose. The monitoring process never outranks the monitored one. These settings express that relationship correctly.
-e
flag: the watchdog was quietly dying on the first agent
The first version was written with set -euo pipefail
. Run manually, it checked all four SPECS entries. Run via launchd, no log entries ever appeared past the first one.
Looking at ~/.claude/logs/outreach-schedule-guard.log
, nothing followed the outreach-ig processing log. .err.log
was empty. Nothing in launchd's journal either. It wasn't "the process didn't start." It was "started and stopped partway."
I found the cause when I manually checked the exit code of launchctl bootout
.
launchctl bootout "gui/$(id -u)/com.lily.outreach-th" 2>/dev/null
echo $?
Output: 36
(error). outreach-th wasn't registered with launchd at that moment (already booted out in the previous cycle), so bootout returned non-zero and -e
terminated the script on the spot.
Two fixes. Change to set -uo pipefail
, and add 2>/dev/null
to bootout
. That alone got all four processed in order. "Stop on error" looks safe, but for this use case it was the setting that killed the watchdog.
actual="$(python3 - "$plist" <<'PY'
import plistlib, sys
...
PY
)"
This worked perfectly in my local terminal, but run via launchd the script process never died and just stayed alive. Nothing in the logs. The guard process kept consuming a trickle of CPU.
I confirmed it was alive with ps aux | grep outreach-schedule-guard
, dug in with the strace equivalent, and found it blocking inside the plist step. The cause: a trailing space after the heredoc end marker PY
.
actual="$(python3 - "$plist" <<'PY ' # ← 末尾スペース
(The comment marks the trailing space.) zsh tolerates this, but the /bin/bash
that launchd invokes keeps waiting for 'PY '
(space included) as the terminator. A line reading PY
never arrives on the input stream, so the Python process hangs waiting on stdin. With no timeout configured, when the guard tries to start on the next 10-minute cycle the previous process is still alive, and you get a double run.
The fix was just removing the trailing space, but the symptom combination of "no logs plus a process that won't die" made diagnosis slow. When you write automation scripts assuming zsh and run them under launchd (bash), you have to visually check heredoc end markers for stray whitespace.
isinstance
trap: an infinite restore loop on single-entry plists
When plistlib reads StartCalendarInterval
, multiple entries come back as list[dict]
. But a single entry comes back as a bare dict.
In the early version, written without knowing this, creating a test plist with only one StartCalendarInterval
entry threw a TypeError
.
TypeError: 'dict' object is not iterable
The Python exception is caught on the guard side and actual
becomes the string ERR
. ERR
never matches any expected value, so every cycle judges "tamper detected" → create backup → overwrite with plistlib.dump → plutil -lint → bootout → bootstrap → log "restored and reloaded," forever. The plist contents are actually correct, yet the guard keeps frantically writing "restored."
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows] # この1行が防波堤
print(','.join(f"{r.get('Hour')}:{r.get('Minute')}" for r in rows))
(The comment reads: this one line is the breakwater.) All production plists have multiple entries, so it never reproduced in production. I only hit it when hand-building a simple test plist, and realized it was a landmine buried where "only the test environment steps on it." plistlib's behavior is documented, but writing "code that only anticipates the multi-entry case" is extremely natural, so you need the habit of checking types before using an API or absorbing them with isinstance
.
stat
syntax: completely different on macOS and Linux
mtime=$(stat -f '%Sm' -t '%F %T' "$plist")
This is macOS (BSD-style stat
) syntax. The Linux equivalent is stat -c '%y' "$plist"
. When I ran unit tests in a Docker container, this line failed with illegal option -- f
and I realized "this doesn't run in some container."
That said, this script is launchd-only, and launchd exists only on macOS, so Linux compatibility has no meaning here. Zero real harm, but it's a snag during development or testing on Linux.
To unpack -f '%Sm' -t '%F %T'
: -f
specifies a format string, %S
means "display the time using the -t
format," and m
is "last modification time (mtime)." The %F %T
passed to -t
is strftime format, so %F
= 2026-08-23
and %T
= 13:07:37
. If you're used to GNU's stat -c
, -f
means something entirely different and it's confusing. When writing macOS-specific stat
, it's faster to run man stat
first or try it in a macOS terminal before writing.
Beyond the four snags above (-e
flag, heredoc space, isinstance
, stat
syntax), unexpected walls keep appearing once you actually build this. Here's an exhaustive list so you don't burn the same hours.
Mistaking the first argument of launchctl bootstrap for a domain
bootstrap "gui/$(id -u)" "$plist"
, gui/501
means "GUI session 501." Confuse it with user/$(id -u)
(background session) or system/
(root only) and you can get a case where nothing errors and nothing silently starts either. For your own user agents, always gui/$(id -u)
.id -u
returns empty instead of a number
Rarely, when launchd starts the script from outside a session, environment variables get stripped and id -u
returns an empty string. You end up with a doubled slash like bootout "gui//com.lily.outreach-ig"
and bootout
fails silently. It's safer to write : "${UID:=$(id -u)}"
at the top of the script as a fallback.
Backup files pile up without limit
Every restore generates a com.lily.outreach-ig.plist.bak-guard-20260823-120412
. If incidents recur intermittently, ~/Library/LaunchAgents/
fills with hundreds of backups. Add one line at the end of the guard script, find "$LA" -name '*.bak-guard-*' -mtime +7 -delete
, and backups older than 7 days are removed automatically.
The plist errors when the log file doesn't exist
If the directory of the path given to StandardOutPath
doesn't exist, launchd refuses to bootstrap the plist at all. In an environment where ~/.claude/logs/
doesn't exist on first launch, the guard itself never starts. mkdir -p "$(dirname "$LOG")"
at the top of the script is mandatory, but the directories pointed to by the plist's StandardOutPath/StandardErrorPath must be created in advance as well.
RunAtLoad: false
delays the first check by 10 minutes
guard.plist's RunAtLoad
is false
. This avoids an unnecessary run right after login, but the flip side is that after a Mac reboot, the first check runs 10 minutes later. If you reboot right after an incident, that 10-minute window is blind. During incident response, get in the habit of manually starting it with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.lily.outreach-schedule-guard.plist
before doing anything else.
Cases where plutil -lint passes but launchd rejects
plutil -lint
validates XML syntax, but launchd's key specification is a separate matter. For example, put only a Hour
key in a StartCalendarInterval
entry and omit Minute
, and plutil -lint
passes while launchd ignores the agent. When writing via plistlib, both keys must always be included, as in {'Hour': int(h), 'Minute': minute}
.Double launch when the guard runs longer than 600 seconds
StartInterval: 600
doesn't mean launchd starts the process every 600 seconds "regardless of whether the previous run finished." In practice, the next interval counts from when the previous run completes. However, if heavy forensic work or accumulated sleep 1
calls slow the whole script down, the "finish → immediate start" cycle can effectively jam. Four agents × restore × sleep 1
takes up to about 4 seconds, which is normally no problem.
Nobody detects it when the guard's own plist is rewritten
This is the design's "last guardian problem." If StartInterval
in com.lily.outreach-schedule-guard.plist
is rewritten, the guard can't detect that on its own. Countermeasures: include "com.lily.outreach-schedule-guard:*:*"
in the SPECS
array so the guard's own plist is compared too, or set up a separate cron that checks from outside with launchctl print gui/$(id -u)/com.lily.outreach-schedule-guard | grep interval
.
The python3 path varies by Homebrew environment
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
declared at the top of the script, /opt/homebrew
may not exist depending on Apple Silicon vs Intel Mac (Intel uses /usr/local
). I support both by adding /usr/local/bin
to guard.plist's EnvironmentVariables
.
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
The slight difference between the plist PATH and the script PATH is intentional. The plist side is self-contained as the launch environment, and the script side serves as a runtime override.
grep -iE "python|node|bash"
matches the guard itself
When the forensic ps collection searches for bash and python, the guard script itself (/bin/bash outreach-schedule-guard.sh
) is a hit. No real harm, but you'll be surprised to find yourself in the "suspects" list when analyzing logs. You can add grep -v "outreach-schedule-guard"
to the filter to exclude yourself.
StartInterval
doesn't count time while the Mac is powered off
launchd's StartInterval
counts time while the system is "asleep," but not while it's fully shut down. If the machine was powered off for a long trip, the first check after power-on is 600 seconds later. This can't be changed, so before a long absence it's worth manually running the guard once and checking the log.
Based on the real code and the real incident, here are the principles to hold onto when writing this kind of "guardian script for automation."
1. Declare expected values in a single array
When "label:minute:hour-list" fits on one line like the SPECS
array, adding or changing monitored targets is just an edit to SPECS
. If monitoring logic and expected values are scattered, changing one creates inconsistency with the other.
2. Know that the -e flag doesn't coexist with for loops
set -euo pipefail
is a handy safety device, but in a script where commands returning non-zero are mixed into a loop, it kills the watchdog. Keep -uo pipefail
, drop -e
, and attach 2>/dev/null
or || true
individually to lines where errors are expected. That's more robust.3. Split logs into three files
Separate the monitoring log the script writes deliberately (.log
), launchd's captured stdout (.out.log
), and bash's unexpected errors (.err.log
). If tail -f outreach-schedule-guard.err.log
is empty, you know instantly there are zero shell-level problems. Mix them into one file and you pay that judgment cost every time.
4. Always validate with plutil -lint before restoring
plistlib.dump()
generates valid XML, but the chance of a byte sequence being corrupted by an interruption mid-write isn't zero. launchctl bootstrap
on a broken plist puts launchd into an undefined state. Always insert the step of re only what passes plutil -lint
.5. Put sleep 1 between bootout and bootstrap
launchctl bootout
completes asynchronously inside launchd. Call bootstrap
right away and it fails as "still registered." One second is enough, but skip it and you get hard-to-reproduce failures depending on the environment.6. Preserve evidence with a backup before overwriting
The order matters: cp
the tampered plist to a timestamped backup, then restore. If the file proving "what it was before" disappears after the restore, it's useless for root-cause investigation later. Pair this with a rule that auto-deletes backups after 7 days.
7. Leave a process list in the forensics
On tamper detection, log python, node, bash, launchctl, and plutil processes with ps -Ao pid,lstart,comm
. Even if it doesn't directly identify the culprit, a process list for the time window is grounds for forming hypotheses. In the 2026-08-23 incident, the forensic log showed that a Playwright driver started at 12:50:32 (PID 82665)
overlapped with the modification time.
8. Absorb plistlib's type wobble with isinstance
StartCalendarInterval
returns a dict
for one entry and list[dict]
for several. Fail to handle this Python standard library behavior and a simple test plist throws a TypeError
, causing the guard to false-positive into an infinite restore loop.
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
These two lines are the breakwater that keeps this from becoming a production outage.
9. Manage the guard with its own launchd agent
Make the script depend on cron or manual runs and monitoring stops the moment cron dies or you forget to run it. Registering the guard with launchd via com.lily.outreach-schedule-guard.plist
lets the OS layer auto-recover from Mac reboots, crashes, and user mistakes.
10. Set the monitoring process's priority below the monitored one
LowPriorityIO: true
, Nice: 10
, and ProcessType: Background
in guard.plist make explicit that the guard is a lightweight process running once every 10 minutes, while ensuring it doesn't interfere with the I/O of the real sales DM process. Setups where the monitoring script runs at higher priority than the production script, defeating the purpose, are surprisingly common.
11. Declare PATH in both the script and the plist
launchd doesn't read your login shell's ~/.zshrc
. Write it not only in the script's export PATH=...
but also in the plist's EnvironmentVariables > PATH
, so python3
, plutil
, and launchctl
are reliably found through either launch path.
12. Skip unnecessary launches with RunAtLoad: false, and understand its limitation
false
. But this setting is inseparable from the fact that "the first check after login is delayed 10 minutes." If you want to verify the schedule manually right after a reboot, kick it manually with launchctl.13. Watch for trailing spaces after bash heredoc end markers
In zsh, a heredoc works fine even with <<'PY '
(space included), but the /bin/bash
that launchd launches keeps waiting for PY
as the terminator and the script blocks forever. The symptom shows up as "no logs plus a process that won't die," which takes time to diagnose. Always put the heredoc end marker on its own line and confirm there's no trailing space.
14. Consolidate monitored plist labels in the SPECS array so changes are one line
SPECS
array. A structure that requires adding configuration across multiple files inevitably leads to omissions and inconsistencies. Choosing a "change SPECS and everything follows" design from the start makes long-term operation easy.15. Note explicitly that the stat options are macOS-specific
stat -f '%Sm' -t '%F %T'
is BSD-style macOS-only syntax. Try to test just this line on Linux and it fails with illegal option -- f
. Since this script is launchd-only, there's no real harm, but it's a source of confusion when writing unit tests in Docker containers or CI environments. A one-line comment wherever a macOS-specific command is used prevents anyone picking up the code from getting stuck.The 2026-08-23 incident, where the launchd plists for my sales DMs were rewritten to a late-night schedule, drove home a reality: "keeping automation running is easier than building a mechanism that detects breakage within 10 minutes and reverts it."
The design philosophy of outreach-schedule-guard.sh
in one sentence: "Assume things will break, and have the OS run a watchdog while the human sleeps." A 73-line bash script packs into one file precise plist comparison via python3+plistlib, evidence preservation in forensic logs, atomic restore through plutil -lint
and launchctl reload
, and a "watchdog for the watchdog" structure where the guard itself is protected by launchd.
The foundation of ¥1.2M a month is an accumulation of unglamorous hardening like this. You end up spending more time making existing automation hard to break than writing flashy new automation scripts. I'm now convinced that's the essence of scaling solo development: separating "building the environment" from "doing the work."
The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day playbook are in a paid note (Japanese).
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*