{"slug": "my-outreach-schedule-got-rewritten-to-2-31-am-without-me-building-a-launchd-that", "title": "My Outreach Schedule Got Rewritten to 2:31 AM Without Me: Building a launchd Guard That Self-Heals in Under 10 Minutes", "summary": "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.", "body_md": "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.\n\nWhen 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**.\n\nMy outreach (sales DMs) is fully automated by four LaunchAgents.\n\n| Agent | Sends per day | Schedule |\n|---|---|---|\n| com.lily.outreach-ig | 8 | 8:20–22:20 (every 2 hours) |\n| com.lily.outreach-th | 7 | 9:38–21:38 (every 2 hours) |\n| com.lily.followers-outreach | 8 | 8:35–22:35 (every 2 hours) |\n| com.lily.outreach-yt | 6 | 10:52–20:52 (every 2 hours) |\n\nThat'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.\n\nThe problem: **these configuration files (plists) got rewritten from outside**. That was a real incident.\n\nIt's right there in the comment at the top of the script.\n\n``` php\n# 2026-08-23 12:04 に outreach-ig が 8回/日 -> 3回/日(2:31/10:31/18:31)、\n# outreach-th が 7回/日 -> 3回/日 に一斉に書き換えられ、\n# 深夜帯に営業DMを送る設定になっていた。\n```\n\n(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.)\n\nIt 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.\n\nI 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**.\n\n\"Fix it when I notice\" is over. The machine fixes it before a human notices.\n\noutreach-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.\n\nOn 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.\n\nSo instead of \"fix it later,\" I designed the solution as **\"automatically revert within 10 minutes.\"**\n\n```\n┌───────────────────────────────────────────────────────────┐\n│  macOS launchd                                            │\n│                                                           │\n│  ┌─────────────────────┐  StartCalendarInterval          │\n│  │ outreach-ig.plist   │  8:20, 10:20, 12:20 ...  ──────►│ browser-slot.sh\n│  │ outreach-th.plist   │  9:38, 11:38, 13:38 ...  ──────►│ run-lane.sh\n│  │ followers-outreach  │  8:35, 10:35, 12:35 ...  ──────►│ （営業DM送信）\n│  │ outreach-yt.plist   │  10:52, 12:52, 14:52 ... ──────►│\n│  └─────────────────────┘                                  │\n│           ▲ 書き換え検知 → 即時復元                        │\n│           │                                               │\n│  ┌─────────────────────────────────────────────┐          │\n│  │ outreach-schedule-guard.plist               │          │\n│  │   StartInterval: 600（10分ごと）            │          │\n│  │        │                                   │          │\n│  │        ▼                                   │          │\n│  │ outreach-schedule-guard.sh                 │          │\n│  │   1. plist を python3+plistlib で読む       │          │\n│  │   2. SPECS の期待値と実値を比較             │          │\n│  │   3. 差異あり → forensicログ + 復元 + reload│          │\n│  └─────────────────────────────────────────────┘          │\n└───────────────────────────────────────────────────────────┘\n```\n\n(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.)\n\nThe 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.\n\nThe core of `outreach-schedule-guard.sh`\n\nis that the monitored targets are declared as a string array of **label:minute:hour-list**.\n\n```\nSPECS=(\n  \"com.lily.outreach-ig:20:8,10,12,14,16,18,20,22\"\n  \"com.lily.outreach-th:38:9,11,13,15,17,19,21\"\n  \"com.lily.followers-outreach:35:8,10,12,14,16,18,20,22\"\n  \"com.lily.outreach-yt:52:10,12,14,16,18,20\"\n)\n```\n\nRead `com.lily.outreach-ig:20:8,10,12,14,16,18,20,22`\n\nas \"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.\n\nTo read `StartCalendarInterval`\n\nfrom the XML plist, I chose Python's standard library `plistlib`\n\nover `/usr/libexec/PlistBuddy`\n\nor hand-rolled XML parsing, and for a reason.\n\n``` python\nactual=\"$(python3 - \"$plist\" <<'PY'\nimport plistlib, sys\ntry:\n    with open(sys.argv[1],'rb') as f: d = plistlib.load(f)\n    rows = d.get('StartCalendarInterval') or []\n    if isinstance(rows, dict): rows = [rows]\n    print(','.join(f\"{r.get('Hour')}:{r.get('Minute')}\" for r in rows))\nexcept Exception as e:\n    print('ERR')\nPY\n)\"\n```\n\n`plistlib.load()`\n\nhandles both binary and XML plists. `StartCalendarInterval`\n\ncomes back as a `dict`\n\nwhen there's a single entry and a `list`\n\nwhen there are several, so `isinstance(rows, dict)`\n\nabsorbs that. The output is a comma-separated string like `8:20,10:20,12:20,...`\n\n.\n\nThe expected value is generated in the same format on the bash side.\n\n```\nexpected=\"\"\nIFS=',' read -r -a harr <<< \"$hours\"\nfor h in \"${harr[@]}\"; do expected=\"${expected}${expected:+,}${h}:${minute}\"; done\n```\n\n`${expected:+,}`\n\nis the idiom for \"prepend a comma only if expected is non-empty.\" It produces `8:20,10:20,...`\n\n, and a plain string comparison against actual is all the diff detection needs.\n\n```\n[ \"$actual\" = \"$expected\" ] && continue\n```\n\nIf they match, `continue`\n\nskips. If they differ, the next block runs.\n\n**Step 1: forensic log**\n\n```\nlog \"$label: 書き換え検知 mtime=$(stat -f '%Sm' -t '%F %T' \"$plist\")\"\nlog \"$label:   現在   = $actual\"\nlog \"$label:   あるべき= $expected\"\nps -Ao pid,lstart,comm | tail -n +2 | while read -r p rest2; do echo \"$p $rest2\"; done \\\n  | grep -iE \"python|node|bash|launchctl|plutil\" | tail -25 | while read -r l; do log \"$label:   ps> $l\"; done\n```\n\n`stat -f '%Sm'`\n\nrecords the plist's last-modified time. The `ps`\n\ncommand 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.\n\n**Step 2: backup**\n\n```\ncp \"$plist\" \"$plist.bak-guard-$(date +%Y%m%d-%H%M%S)\"\n```\n\nThe tampered plist is saved under a name like `com.lily.outreach-ig.plist.bak-guard-20260823-120412`\n\n. This preserves evidence so the pre-restore state can be reproduced afterward.\n\n**Step 3: overwrite with correct values via plistlib**\n\n``` python\npython3 - \"$plist\" \"$minute\" \"$hours\" <<'PY'\nimport plistlib, sys\npath, minute, hours = sys.argv[1], int(sys.argv[2]), sys.argv[3]\nwith open(path,'rb') as f: d = plistlib.load(f)\nd['StartCalendarInterval'] = [{'Hour': int(h), 'Minute': minute} for h in hours.split(',')]\nwith open(path,'wb') as f: plistlib.dump(d, f)\nPY\n```\n\nOther keys such as `Label`\n\n, `ProgramArguments`\n\n, and `EnvironmentVariables`\n\nare left untouched. Only `StartCalendarInterval`\n\nis replaced. Because it's written out with plistlib.dump, the generated XML conforms to Apple's official format.\n\n**Step 4: validate and reload**\n\n```\nif plutil -lint \"$plist\" >/dev/null 2>&1; then\n  launchctl bootout \"gui/$(id -u)/$label\" 2>/dev/null\n  sleep 1\n  if launchctl bootstrap \"gui/$(id -u)\" \"$plist\" 2>/dev/null; then\n    log \"$label: 復元して再読込した\"\n  else\n    log \"$label: 🔴 bootstrap に失敗した（手動確認が要る）\"\n  fi\nelse\n  log \"$label: 🔴 復元後のplistが壊れている（戻していない）\"\nfi\n```\n\n`plutil -lint`\n\nconfirms the file is valid before `launchctl bootout`\n\n→ `bootstrap`\n\n. `bootout`\n\nis 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`\n\nfails, 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.)\n\n```\n<key>StartInterval</key>\n<integer>600</integer>\n```\n\n`StartInterval`\n\nin `com.lily.outreach-schedule-guard.plist`\n\nis 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.\n\nLog output is split into two files, `~/.claude/logs/outreach-schedule-guard.out.log`\n\nand `outreach-schedule-guard.err.log`\n\n. Normal logs go to `stdout`\n\n, unexpected bash errors to `stderr`\n\n, so there's no mixed noise when monitoring in real time with `tail -f`\n\n.\n\n`-e`\n\nis dropped from `set -uo pipefail`\n\nLine one of the script contains a quiet but important decision.\n\n```\nset -uo pipefail\n```\n\nThere's no `-e`\n\n(exit immediately on error). That's deliberate. With `-euo pipefail`\n\n, the whole script dies the moment `launchctl bootout`\n\nreturns non-zero.\n\n`bootout`\n\nreturns 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`\n\non the first one would skip monitoring of the remaining three. That's not a watchdog. So I keep `-u`\n\n(error on undefined variables) and `pipefail`\n\n(catch failures mid-pipe) but drop `-e`\n\n. The `bootout`\n\ncall also gets `2>/dev/null`\n\nto silence the \"not registered\" message on stderr.\n\n```\nlaunchctl bootout \"gui/$(id -u)/$label\" 2>/dev/null\nexport PATH=\"/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin\"\n```\n\nScripts started by launchd inherit nothing from your login shell's `~/.zshrc`\n\nor `~/.zprofile`\n\n. However carefully you've grown `PATH`\n\nin the terminal, none of it reaches the launchd execution environment. If `python3`\n\ncan't be found, every step that uses plistlib goes silent.\n\nThe same PATH is defined in guard.plist's `EnvironmentVariables`\n\nblock.\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>\n</dict>\n```\n\nThe monitored outreach-ig.plist, by contrast, has a somewhat longer PATH.\n\n```\n<key>BROWSER_SLOT_TIMEOUT_SEC</key>\n<string>2700</string>\n<key>PATH</key>\n<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...</string>\n```\n\nIt has `BROWSER_SLOT_TIMEOUT_SEC`\n\n(45-minute session cap) and the nvm Node.js path, because outreach-ig drives a browser via Playwright. The guard only uses `python3`\n\n, `stat`\n\n, `launchctl`\n\n, and `plutil`\n\n, so Homebrew plus the system PATH is enough. The \"minimum required\" PATH differs between watcher and watched.\n\n```\nlog() { printf '%s %s\\n' \"$(date '+%F %T')\" \"$*\" >> \"$LOG\"; }\n```\n\n`printf`\n\ninstead of `echo`\n\nbecause `echo`\n\ninterprets flags like `-e`\n\ndepending on the shell implementation. `date '+%F %T'`\n\nproduces a timestamp in the form `2026-08-23 13:07:37`\n\n.\n\n`$LOG`\n\nis a file path defined inside the script (`~/.claude/logs/outreach-schedule-guard.log`\n\n) and appended to directly. It's separate from the StandardOutPath and StandardErrorPath defined in guard.plist.\n\n```\n<key>StandardOutPath</key>\n<string>~/.claude/logs/outreach-schedule-guard.out.log</string>\n<key>StandardErrorPath</key>\n<string>~/.claude/logs/outreach-schedule-guard.err.log</string>\n```\n\nThe result is three log files with distinct roles. ** .log** is the monitoring output the guard writes on purpose.\n\n`.out.log`\n\n`.err.log`\n\n`tail -f ~/.claude/logs/outreach-schedule-guard.err.log`\n\nis 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.\n\n```\n2026-08-23 13:07:37 com.lily.outreach-th: 書き換え検知 mtime=2026-08-23 13:07:37\n2026-08-23 13:07:37 com.lily.outreach-th:   現在   = 2:31,10:31,18:31\n2026-08-23 13:07:37 com.lily.outreach-th:   あるべき= 9:38,11:38,13:38,15:38,17:38,19:38,21:38\n2026-08-23 13:07:37 com.lily.outreach-th:   ps> 80406 日  8/23 12:50:01 2026  Python\n2026-08-23 13:07:37 com.lily.outreach-th:   ps> 80842 金  8/21 11:06:32 2026  node\n2026-08-23 13:07:37 com.lily.outreach-th:   ps> 81332 日  8/23 12:50:08 2026  bash\n2026-08-23 13:07:37 com.lily.outreach-th:   ps> 82665 日  8/23 12:50:32 2026  playwright/driver/node\n（以下、合計25件）\n2026-08-23 13:07:38 com.lily.outreach-th: 復元して再読込した\n```\n\n(Line labels: \"書き換え検知\" = tamper detected; \"現在\" = current; \"あるべき\" = expected; \"（以下、合計25件）\" = 25 entries in total; \"復元して再読込した\" = restored and reloaded.)\n\nA few facts can be read from this.\n\n** 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.\n\n**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.\"\n\n**\"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.\n\n`plutil -lint`\n\nand `sleep 1`\n\nare necessary\n\n```\nif plutil -lint \"$plist\" >/dev/null 2>&1; then\n  launchctl bootout \"gui/$(id -u)/$label\" 2>/dev/null\n  sleep 1\n  if launchctl bootstrap \"gui/$(id -u)\" \"$plist\" 2>/dev/null; then\n    log \"$label: 復元して再読込した\"\n  else\n    log \"$label: 🔴 bootstrap に失敗した（手動確認が要る）\"\n  fi\nelse\n  log \"$label: 🔴 復元後のplistが壊れている（戻していない）\"\nfi\n```\n\n`plutil -lint`\n\nis Apple's official plist validator. `plistlib.dump()`\n\ngenerally 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.\n\n`sleep 1`\n\nis an explicit wait for launchd's processing. The `bootout`\n\ncommand sends launchd a request to unload the label, but launchd completes that work asynchronously. Call `bootstrap`\n\nimmediately and launchd may decide it's \"still registered\" and fail.\n\nLooking again at the guard's own plist, four keys are set explicitly.\n\n```\n<key>RunAtLoad</key>\n<false/>\n<key>LowPriorityIO</key>\n<true/>\n<key>Nice</key>\n<integer>10</integer>\n<key>ProcessType</key>\n<string>Background</string>\n```\n\n** RunAtLoad: false**: don't run immediately at bootstrap time (e.g. login). Set to true, the guard would run on every startup. The fixed\n\n`StartInterval: 600`\n\nis 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.\n\n`-e`\n\nflag: the watchdog was quietly dying on the first agent\nThe first version was written with `set -euo pipefail`\n\n. Run manually, it checked all four SPECS entries. Run via launchd, no log entries ever appeared past the first one.\n\nLooking at `~/.claude/logs/outreach-schedule-guard.log`\n\n, nothing followed the outreach-ig processing log. `.err.log`\n\nwas empty. Nothing in launchd's journal either. It wasn't \"the process didn't start.\" It was \"started and stopped partway.\"\n\nI found the cause when I manually checked the exit code of `launchctl bootout`\n\n.\n\n```\nlaunchctl bootout \"gui/$(id -u)/com.lily.outreach-th\" 2>/dev/null\necho $?\n```\n\nOutput: `36`\n\n(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`\n\nterminated the script on the spot.\n\nTwo fixes. Change to `set -uo pipefail`\n\n, and add `2>/dev/null`\n\nto `bootout`\n\n. 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.\n\n``` python\nactual=\"$(python3 - \"$plist\" <<'PY'\nimport plistlib, sys\n...\nPY\n)\"\n```\n\nThis 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.\n\nI confirmed it was alive with `ps aux | grep outreach-schedule-guard`\n\n, 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`\n\n.\n\n```\nactual=\"$(python3 - \"$plist\" <<'PY '   # ← 末尾スペース\n```\n\n(The comment marks the trailing space.) zsh tolerates this, but the `/bin/bash`\n\nthat launchd invokes keeps waiting for `'PY '`\n\n(space included) as the terminator. A line reading `PY`\n\nnever 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.\n\nThe 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.\n\n`isinstance`\n\ntrap: an infinite restore loop on single-entry plists\nWhen plistlib reads `StartCalendarInterval`\n\n, multiple entries come back as `list[dict]`\n\n. But **a single entry comes back as a bare dict**.\n\nIn the early version, written without knowing this, creating a test plist with only one `StartCalendarInterval`\n\nentry threw a `TypeError`\n\n.\n\n```\nTypeError: 'dict' object is not iterable\n```\n\nThe Python exception is caught on the guard side and `actual`\n\nbecomes the string `ERR`\n\n. `ERR`\n\nnever 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.\"\n\n```\nrows = d.get('StartCalendarInterval') or []\nif isinstance(rows, dict): rows = [rows]   # この1行が防波堤\nprint(','.join(f\"{r.get('Hour')}:{r.get('Minute')}\" for r in rows))\n```\n\n(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`\n\n.\n\n`stat`\n\nsyntax: completely different on macOS and Linux\n\n```\nmtime=$(stat -f '%Sm' -t '%F %T' \"$plist\")\n```\n\nThis is macOS (BSD-style `stat`\n\n) syntax. The Linux equivalent is `stat -c '%y' \"$plist\"`\n\n. When I ran unit tests in a Docker container, this line failed with `illegal option -- f`\n\nand I realized \"this doesn't run in some container.\"\n\nThat 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.\n\nTo unpack `-f '%Sm' -t '%F %T'`\n\n: `-f`\n\nspecifies a format string, `%S`\n\nmeans \"display the time using the `-t`\n\nformat,\" and `m`\n\nis \"last modification time (mtime).\" The `%F %T`\n\npassed to `-t`\n\nis strftime format, so `%F`\n\n= `2026-08-23`\n\nand `%T`\n\n= `13:07:37`\n\n. If you're used to GNU's `stat -c`\n\n, `-f`\n\nmeans something entirely different and it's confusing. When writing macOS-specific `stat`\n\n, it's faster to run `man stat`\n\nfirst or try it in a macOS terminal before writing.\n\nBeyond the four snags above (`-e`\n\nflag, heredoc space, `isinstance`\n\n, `stat`\n\nsyntax), unexpected walls keep appearing once you actually build this. Here's an exhaustive list so you don't burn the same hours.\n\n**Mistaking the first argument of launchctl bootstrap for a domain**\n\n`bootstrap \"gui/$(id -u)\" \"$plist\"`\n\n, `gui/501`\n\nmeans \"GUI session 501.\" Confuse it with `user/$(id -u)`\n\n(background session) or `system/`\n\n(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)`\n\n.`id -u`\n\nreturns empty instead of a number\n\nRarely, when launchd starts the script from outside a session, environment variables get stripped and `id -u`\n\nreturns an empty string. You end up with a doubled slash like `bootout \"gui//com.lily.outreach-ig\"`\n\nand `bootout`\n\nfails silently. It's safer to write `: \"${UID:=$(id -u)}\"`\n\nat the top of the script as a fallback.\n\n**Backup files pile up without limit**\n\nEvery restore generates a `com.lily.outreach-ig.plist.bak-guard-20260823-120412`\n\n. If incidents recur intermittently, `~/Library/LaunchAgents/`\n\nfills with hundreds of backups. Add one line at the end of the guard script, `find \"$LA\" -name '*.bak-guard-*' -mtime +7 -delete`\n\n, and backups older than 7 days are removed automatically.\n\n**The plist errors when the log file doesn't exist**\n\nIf the **directory** of the path given to `StandardOutPath`\n\ndoesn't exist, launchd refuses to bootstrap the plist at all. In an environment where `~/.claude/logs/`\n\ndoesn't exist on first launch, the guard itself never starts. `mkdir -p \"$(dirname \"$LOG\")\"`\n\nat 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.\n\n`RunAtLoad: false`\n\ndelays the first check by 10 minutes\n\nguard.plist's `RunAtLoad`\n\nis `false`\n\n. 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`\n\nbefore doing anything else.\n\n**Cases where plutil -lint passes but launchd rejects**\n\n`plutil -lint`\n\nvalidates XML syntax, but launchd's key specification is a separate matter. For example, put only a `Hour`\n\nkey in a `StartCalendarInterval`\n\nentry and omit `Minute`\n\n, and `plutil -lint`\n\npasses while launchd ignores the agent. When writing via plistlib, both keys must always be included, as in `{'Hour': int(h), 'Minute': minute}`\n\n.**Double launch when the guard runs longer than 600 seconds**\n\n`StartInterval: 600`\n\ndoesn'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`\n\ncalls slow the whole script down, the \"finish → immediate start\" cycle can effectively jam. Four agents × restore × `sleep 1`\n\ntakes up to about 4 seconds, which is normally no problem.\n\n**Nobody detects it when the guard's own plist is rewritten**\n\nThis is the design's \"last guardian problem.\" If `StartInterval`\n\nin `com.lily.outreach-schedule-guard.plist`\n\nis rewritten, the guard can't detect that on its own. Countermeasures: include `\"com.lily.outreach-schedule-guard:*:*\"`\n\nin the `SPECS`\n\narray 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`\n\n.\n\n**The python3 path varies by Homebrew environment**\n\n`export PATH=\"/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin\"`\n\ndeclared at the top of the script, `/opt/homebrew`\n\nmay not exist depending on Apple Silicon vs Intel Mac (Intel uses `/usr/local`\n\n). I support both by adding `/usr/local/bin`\n\nto guard.plist's `EnvironmentVariables`\n\n.\n\n```\n  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>\n```\n\nThe 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.\n\n`grep -iE \"python|node|bash\"`\n\nmatches the guard itself\n\nWhen the forensic ps collection searches for bash and python, the guard script itself (`/bin/bash outreach-schedule-guard.sh`\n\n) 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\"`\n\nto the filter to exclude yourself.\n\n`StartInterval`\n\ndoesn't count time while the Mac is powered off\n\nlaunchd's `StartInterval`\n\ncounts 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.\n\nBased on the real code and the real incident, here are the principles to hold onto when writing this kind of \"guardian script for automation.\"\n\n**1. Declare expected values in a single array**\n\nWhen \"label:minute:hour-list\" fits on one line like the `SPECS`\n\narray, adding or changing monitored targets is just an edit to `SPECS`\n\n. If monitoring logic and expected values are scattered, changing one creates inconsistency with the other.\n\n**2. Know that the -e flag doesn't coexist with for loops**\n\n`set -euo pipefail`\n\nis 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`\n\n, drop `-e`\n\n, and attach `2>/dev/null`\n\nor `|| true`\n\nindividually to lines where errors are expected. That's more robust.**3. Split logs into three files**\n\nSeparate the monitoring log the script writes deliberately (`.log`\n\n), launchd's captured stdout (`.out.log`\n\n), and bash's unexpected errors (`.err.log`\n\n). If `tail -f outreach-schedule-guard.err.log`\n\nis empty, you know instantly there are zero shell-level problems. Mix them into one file and you pay that judgment cost every time.\n\n**4. Always validate with plutil -lint before restoring**\n\n`plistlib.dump()`\n\ngenerates valid XML, but the chance of a byte sequence being corrupted by an interruption mid-write isn't zero. `launchctl bootstrap`\n\non a broken plist puts launchd into an undefined state. Always insert the step of reloading only what passes `plutil -lint`\n\n.**5. Put sleep 1 between bootout and bootstrap**\n\n`launchctl bootout`\n\ncompletes asynchronously inside launchd. Call `bootstrap`\n\nright 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**\n\nThe order matters: `cp`\n\nthe 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.\n\n**7. Leave a process list in the forensics**\n\nOn tamper detection, log python, node, bash, launchctl, and plutil processes with `ps -Ao pid,lstart,comm`\n\n. 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)`\n\noverlapped with the modification time.\n\n**8. Absorb plistlib's type wobble with isinstance**\n\n`StartCalendarInterval`\n\nreturns a `dict`\n\nfor one entry and `list[dict]`\n\nfor several. Fail to handle this Python standard library behavior and a simple test plist throws a `TypeError`\n\n, causing the guard to false-positive into an infinite restore loop.\n\n```\nrows = d.get('StartCalendarInterval') or []\nif isinstance(rows, dict): rows = [rows]\n```\n\nThese two lines are the breakwater that keeps this from becoming a production outage.\n\n**9. Manage the guard with its own launchd agent**\n\nMake 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`\n\nlets the OS layer auto-recover from Mac reboots, crashes, and user mistakes.\n\n**10. Set the monitoring process's priority below the monitored one**\n\n`LowPriorityIO: true`\n\n, `Nice: 10`\n\n, and `ProcessType: Background`\n\nin 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.\n\n**11. Declare PATH in both the script and the plist**\n\nlaunchd doesn't read your login shell's `~/.zshrc`\n\n. Write it not only in the script's `export PATH=...`\n\nbut also in the plist's `EnvironmentVariables > PATH`\n\n, so `python3`\n\n, `plutil`\n\n, and `launchctl`\n\nare reliably found through either launch path.\n\n**12. Skip unnecessary launches with RunAtLoad: false, and understand its limitation**\n\n`false`\n\n. 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**\n\nIn zsh, a heredoc works fine even with `<<'PY '`\n\n(space included), but the `/bin/bash`\n\nthat launchd launches keeps waiting for `PY`\n\nas 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.\n\n**14. Consolidate monitored plist labels in the SPECS array so changes are one line**\n\n`SPECS`\n\narray. 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**\n\n`stat -f '%Sm' -t '%F %T'`\n\nis BSD-style macOS-only syntax. Try to test just this line on Linux and it fails with `illegal option -- f`\n\n. 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.\"\n\nThe design philosophy of `outreach-schedule-guard.sh`\n\nin 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`\n\nand `launchctl reload`\n\n, and a \"watchdog for the watchdog\" structure where the guard itself is protected by launchd.\n\nThe 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.\"\n\nThe full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day playbook are in a paid note (Japanese).\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/my-outreach-schedule-got-rewritten-to-2-31-am-without-me-building-a-launchd-that", "canonical_source": "https://dev.to/bokuwalily/my-outreach-schedule-got-rewritten-to-231-am-without-me-building-a-launchd-guard-that-self-heals-4bii", "published_at": "2026-09-03 00:00:06+00:00", "updated_at": "2026-09-03 00:22:42.897452+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Claude Code", "macOS", "launchd", "LaunchAgents"], "alternates": {"html": "https://wpnews.pro/news/my-outreach-schedule-got-rewritten-to-2-31-am-without-me-building-a-launchd-that", "markdown": "https://wpnews.pro/news/my-outreach-schedule-got-rewritten-to-2-31-am-without-me-building-a-launchd-that.md", "text": "https://wpnews.pro/news/my-outreach-schedule-got-rewritten-to-2-31-am-without-me-building-a-launchd-that.txt", "jsonld": "https://wpnews.pro/news/my-outreach-schedule-got-rewritten-to-2-31-am-without-me-building-a-launchd-that.jsonld"}}