Six months after being laid off, I'd rebuilt my income from zero to ยฅ1.2M/month on an autonomous setup. Then one morning at 8:00, it just wasn't there โ no error, no alert, nothing. The cause: a macOS update had quietly disabled the cron daemon. My fix was a 97-line shell script that parses crontab line by line and auto-generates launchd plists.
Why This Approach Works
"It Should Be Running" Is the Most Dangerous Kind of Confidence
Back when my side business was earning ยฅ600K/month, nearly every yen of that automation benefit rode on cron jobs. Timing note publications, scheduling social posts, daily data aggregation โ all of it lined up in crontab -l. When I was laid off and dropped to zero, rebuilding the environment with Claude Code, I decided carrying the crontab over as-is was the fastest path.
Right after upgrading to macOS Sequoia (15.x), nothing appeared to have changed. Run crontab -l and every entry is still there. But the daemon isn't running. Since macOS Ventura, Apple has been progressively decoupling the cron daemon from the user session, and on Sequoia/Tahoe it's perfectly normal to have /usr/sbin/cron present while launchctl list | grep cron returns nothing at all.
The reason I was slow to notice is that when automation stops, no error appears. My assumption was that if cron isn't running, an error mail lands in /var/mail/<username> โ and that assumption had collapsed. On Sequoia it doesn't reach the post office by default. The 8:00 daily brief doesn't arrive, the 11:00 social post doesn't go out, and only then do you notice. That "silent death" is what scares me.
Why launchd Is the Right Answer
On macOS, process launching and management belongs to launchd (PID 1). cron survives only as historical compatibility; what Apple actually recommends is job management via launchd. launchd handles automatic restarts when a daemon crashes, automatic execution after wake for jobs scheduled while the machine was asleep, direct redirection of stdout/stderr to files, and explicit injection of environment variables โ all declaratively, in a single plist file.
cron lets you write */5 * * * * cmd on one line; a launchd plist becomes 20โ30 lines of XML. That verbosity is the biggest psychological barrier to migrating to launchd. Rewriting ten of them by hand isn't realistic. So you generate them with a script.
Looking at one plist that's actually in production makes the structure click. Here's how ~/Library/LaunchAgents/com.shun.daily-brief.plist is composed (excerpted from the real file, paths converted to ~ notation):
<key>Label</key>
<string>com.shun.daily-brief</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>8</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Hour</key><integer>10</integer>
<key>Minute</key><integer>30</integer>
</dict>
</array>
<key>ProgramArguments</key>
<array>
<string>~/.claude/scripts/claude-quota-guard.py</string>
<string>--job</string>
<string>com.shun.daily-brief</string>
<string>--</string>
<string>/bin/bash</string>
<string>~/.claude/scripts/daily-brief.sh</string>
</array>
<key>LowPriorityIO</key><true/>
<key>Nice</key><integer>10</integer>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key>
<string>~/.claude/logs/com.shun.daily-brief.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/com.shun.daily-brief.log</string>
Three things stand out.
Explicit EnvironmentVariables. launchd does not read your shell configuration (.zshrc, .bashrc). A script that uses node installed via nvm has no PATH to it under launchd management and dies with node: command not found. This accounts for 90% of the cases where a job migrated from cron suddenly stops working. Writing PATH explicitly into the plist guarantees the same binary gets called no matter what the shell is.
The array form of StartCalendarInterval. When you want to run multiple times per day, you line up <dict> entries inside an <array>. daily-brief runs twice, at 8:00 and 10:30. In cron you'd write 0 8,10 * * *, but launchd requires a dictionary per time. How far the auto-generation script covers this notational gap ties into the pitfalls described later.
LowPriorityIO and Nice. Background jobs get lowered I/O priority and a CPU scheduler nice value of 10. It's a setting to minimize impact on foreground work (editor, browser), consistent with the "erase your presence" philosophy of an autonomous environment.
What It Means to Invest in the Environment, Not the Work
Of that ยฅ1.2M/month breakdown, almost none of it is me moving my hands. Most of the note series, social updates, and data aggregation are automated. The maintenance cost of this environment comes down to moving cron onto a foundation that actually runs. The goal of being under launchd management is that a scheduled task you wrote once is still running three years later. Apple's launchd is a stable API unchanged since macOS 10.4 (2005), and it doesn't "die unnoticed" the way cron does. launchctl list com.shun.daily-brief shows you LastExitStatus and the next scheduled run instantly.
The 90 minutes spent setting up the environment is an investment that buys back 5 minutes ร 365 days (= 30 hours) of "let me check whether it's actually running" every morning.
The Overall Flow
Map of the Processing
crontab -l
โ grep -vE '^\s*#' | grep -v '^$' โ ใณใกใณใ่กใป็ฉบ่กใ้คๅค
โ
[1่กใใจใซใซใผใ]
โ awk '{print $1...$5}' ใง schedule ใใฃใผใซใๆฝๅบ
โ cut -d' ' -f6- ใง cmd ้จๅใๅใๅบใ
โ basename ใใใฉใใซ็ๆ โ com.shun.<script-name>
โ
StartCalendarInterval XML ็ตใฟ็ซใฆ
โ โป */N ๅฝขๅผใฏ้ๅฏพๅฟ๏ผๅบๅฎๅคใฎใฟ๏ผโ ใใใ่ฝใจใ็ฉด
โ
plist ใใกใคใซๆธใๅบใ
โ [dry] ~/.claude/scripts/launchd-proposed/*.plist
โ [apply] ~/Library/LaunchAgents/*.plist
+ launchctl unload โ launchctl load
โ
โ ๏ธ ่ญฆๅ: crontab ใใๆๅๅ้คใใชใใจไบ้่ตทๅ
Dissecting the Script (All 97 Lines)
The script lives at ~/.claude/scripts/cron-to-launchd.sh, and there are two ways to use it.
# ๅทฎๅ็ขบ่ช๏ผใใกใคใซใๆธใใ ใใloadใใชใ๏ผ
~/.claude/scripts/cron-to-launchd.sh dry
# ๆฌ็ชๅๆ ๏ผLaunchAgentsใซใณใใผใใฆlaunchctl load๏ผ
~/.claude/scripts/cron-to-launchd.sh apply
Call it with no arguments and dry is the default (MODE="${1:-dry}"). The iron rule is to not jump straight to apply โ run dry first and eyeball the generated output.
Phase 1: Reading and parsing the crontab (lines 20โ28)
CRON_LINES=()
while IFS= read -r line; do
[ -n "$line" ] && CRON_LINES+=("$line")
done < <(crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -v '^$')
As the comment bash 3.2 ไบๆ indicates, the bash that ships with macOS is version 3.2 (Apple hasn't updated it for GPLv2 reasons). mapfile and readarray aren't available in 3.2, so the array is built with a while IFS= read -r loop. crontab -l 2>/dev/null swallows the error when the crontab is empty, grep -vE '^\s*#' strips comment lines, and grep -v '^$' strips blank lines.
Phase 2: Splitting each line into schedule and cmd (lines 28โ38)
minute=$(echo "$line" | awk '{print $1}')
hour=$(echo "$line" | awk '{print $2}')
dom=$(echo "$line" | awk '{print $3}')
mon=$(echo "$line" | awk '{print $4}')
dow=$(echo "$line" | awk '{print $5}')
cmd=$(echo "$line" | cut -d' ' -f6-)
The cron format min hour dom mon dow cmd... is pulled apart field by field with awk. Since cmd takes everything from the sixth field onward via cut -d' ' -f6-, it picks up the command correctly no matter how many arguments it has.
The label generation logic (lines 38โ40):
script=$(echo "$cmd" | grep -oE '~/.claude/scripts/[^ ]+' | head -1 | xargs basename 2>/dev/null)
if [ -z "$script" ]; then
script="$(echo "$cmd" | awk '{print $1}' | xargs basename 2>/dev/null)-${minute}${hour}"
fi
label="com.shun.$(echo "$script" | sed -E 's/\.[a-z]+$//' | tr '_' '-')"
Scripts under ~/.claude/scripts/ get labeled from the basename with the extension stripped. For example, daily-brief.sh becomes com.shun.daily-brief. Other, general-purpose commands (find, backup-rotate, and so on) secure uniqueness with command name + minute + hour. Underscores are converted to hyphens (launchd Label convention).
Phase 3: Assembling the StartCalendarInterval XML (lines 44โ52)
cal_xml=" <key>StartCalendarInterval</key>\n <dict>\n"
# */N ๅจๆใฏ launchd ใงใฏ่คๆฐใจใณใใชใงๅ็พใใๅฟ
่ฆ โ ใใใงใฏๅบๅฎๅคใ ใๅฏพๅฟ
if [ "$minute" != "*" ]; then cal_xml+=" <key>Minute</key><integer>${minute}</integer>\n"; fi
if [ "$hour" != "*" ]; then cal_xml+=" <key>Hour</key><integer>${hour}</integer>\n"; fi
if [ "$dom" != "*" ]; then cal_xml+=" <key>Day</key><integer>${dom}</integer>\n"; fi
if [ "$mon" != "*" ]; then cal_xml+=" <key>Month</key><integer>${mon}</integer>\n"; fi
if [ "$dow" != "*" ]; then cal_xml+=" <key>Weekday</key><integer>${dow}</integer>\n"; fi
cal_xml+=" </dict>"
If a field is * (wildcard), the corresponding key is omitted from the XML โ that's the semantics of launchd's StartCalendarInterval. For instance, 0 8 * * * (8:00 every day) only needs Hour=8, Minute=0; omitting Day/Month/Weekday is what makes it "every day."
Phase 4: Writing out the plist body (lines 54โ76)
cat > "$plist" <<XMLEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-c</string>
<string>${cmd//&/&}</string>
</array>
$(echo -e "${cal_xml}")
<key>StandardOutPath</key>
<string>${log}</string>
<key>StandardErrorPath</key>
<string>${log}</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
XMLEOF
The command is wrapped as /bin/zsh -c "cmd". Commands that were running under cron often depend on shell expansion (~ expansion, globbing), and there are cases where passing them directly to ProgramArguments doesn't work. Going through zsh absorbs that difference. ${cmd//&/&} is XML escaping โ a command containing & would produce invalid XML, so it's substituted here. Logs send both stdout and stderr together to ~/.claude/logs/${label}.log.
Phase 5: Deployment in apply mode (lines 84โ96)
if [ "$MODE" = "apply" ]; then
for f in "$PROPOSED"/*.plist; do
cp "$f" "$TARGET_DIR/"
launchctl unload "$TARGET_DIR/$(basename $f)" 2>/dev/null
launchctl load "$TARGET_DIR/$(basename $f)"
echo " loaded: $(basename $f)"
done
echo ""
echo "๐จ cron ่กใฏ **ๆๅใงๅ้คใใฆใใ ใใ**: crontab -e"
echo "(่ชคใฃใฆ cron+launchd ไธกๆน่ตฐใใฎใ้ฟใใใใ)"
fi
launchctl unload is called first for idempotency. Trying to load a plist that's already loaded results in an error. Unloading beforehand means running apply any number of times produces the same result. But there's one caveat โ the script only prints a warning after apply saying "please delete from crontab manually"; it doesn't automate the deletion. Leave the cron lines in place and, whenever macOS eventually revives the cron daemon, you get double execution from cron + launchd.
Implementation Details
set -uo pipefail โ Why -e Was Left Out
The declaration at the top of the script is set -uo pipefail (line 9 of the real file). Some of you may have noticed -e (exit immediately on error) isn't there. That's an intentional design decision.
Look at the loop in apply mode (lines 84โ96).
launchctl unload "$TARGET_DIR/$(basename $f)" 2>/dev/null
launchctl load "$TARGET_DIR/$(basename $f)"
launchctl unload returns a non-zero exit code if the target plist isn't loaded yet. With -e enabled, the script dies on the very first unload of the first plist. 2>/dev/null suppresses the error output, but the exit code remains. Omitting -e is what delivers the idempotent behavior of "keep the loop going even if unload fails."
For the same reason, crontab -l 2>/dev/null (line 22) is safe. In a user environment with an empty crontab, crontab -l exits non-zero with crontab: no crontab for <username>, but 2>/dev/null swallows it and the loop proceeds. With -e, it would have died right there.
-u (error on undefined variables) and -o pipefail (propagating pipe failures) stay. Those are guards you need โ for catching variable name typos and failures partway through a pipe. Only -e gets in the way โ and that judgment call is a recurring pattern in shell script error handling.
The Label-Generation Regex and Its Absolute-Path Dependency
Read the label generation logic on line 35 precisely and one important specification becomes visible.
script=$(echo "$cmd" | grep -oE '~/.claude/scripts/[^ ]+' | head -1 | xargs basename 2>/dev/null)
Note that the regex matches on the absolute path, not ~/.claude/scripts/. If the crontab entry was written as ~/.claude/scripts/daily-brief.sh, this regex won't match, because ~ is recorded as a literal string before the shell expands it. If it doesn't match, the script variable ends up empty and falls through to the fallback.
if [ -z "$script" ]; then
script="$(echo "$cmd" | awk '{print $1}' | xargs basename 2>/dev/null)-${minute}${hour}"
fi
The fallback is "basename of the command + minute + hour." For example, if you registered ~/.claude/scripts/daily-brief.sh with 0 8 * * *, the label becomes com.shun.daily-brief-08. Not daily-brief but daily-brief-08. That discrepancy breeds confusion later when you're chasing logs.
Always write absolute paths when registering in the crontab โ that's the only correct way to coexist with this script.
How the */N Format Breaks โ Traced Through the Code
Let's confirm the "*/N unsupported" point raised earlier through the actual code flow. Say the crontab has the line */15 * * * * ~/.claude/scripts/health-check.sh. What happens?
minute=$(echo "*/15 * * * * ~/.claude/scripts/health-check.sh" | awk '{print $1}')
# โ "*/15"
Then the conditional:
if [ "$minute" != "*" ]; then
cal_xml+=" <key>Minute</key><integer>${minute}</integer>\n"
fi
"*/15" != "*" is true, so it passes the condition, and the generated XML is:
<key>Minute</key><integer>*/15</integer>
The string */15 ends up inside an <integer> tag. It parses as XML, more or less, but when launchd loads the plist it gets rejected by the validation that "Minute must be an integer from 0 to 59." launchctl load returns a non-zero exit code, loaded: still gets printed, but scheduling was never actually enabled.
This "looks like the load went through but it isn't actually running" state is nasty, and it shows up again in the next section.
The Intent and Limits of the /bin/zsh -c Wrapper
The ProgramArguments in the generated plist (lines 54โ66):
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-c</string>
<string>${cmd}</string>
</array>
Wrapping the command in zsh is there to get the ~ expansion, environment variable references, and glob patterns that tend to appear in cron entries interpreted. Pass a command directly to ProgramArguments and execvp is called without shell expansion, so ~ gets passed through as a literal string and you get a file-not-found error.
That said, even with /bin/zsh -c, launchd does not read your .zshrc. That's launchd's design. It starts zsh in non-login script mode rather than interactive mode, so even if you've written source ~/.zshrc, it isn't loaded. As a result, processes start in a state where node managed by nvm, python from pyenv, and the various Homebrew commands have no PATH to them.
Look at the generated plist template and there's no EnvironmentVariables key (not anywhere across lines 54โ76). That's exactly why daily-brief.plist has EnvironmentVariables appended by hand. The plists the script auto-generates do not include this PATH injection.
Where I Got Stuck
Stuck 1: */15 * * * * Failed Silently
Symptom. Running apply printed loaded: com.shun.health-check.plist. But 15 minutes later, and 30 minutes later, nothing was written to ~/.claude/logs/com.shun.health-check.log.
launchctl list com.shun.health-check
# โ Could not find service "com.shun.health-check" in domain for port
A service that should be loaded doesn't exist in launchctl's list.
Cause. */15 was written straight into <integer>*/15</integer>, and launchd internally rejected the plist during validation. Because the launchctl load command itself returned exit code 0 (behavior on macOS Sequoia), the script's echo "loaded:" ran anyway. With no error shown, the service simply didn't exist.
Fix. Validating the plist with plutil -lint ~/.claude/scripts/launchd-proposed/com.shun.health-check.plist rejects it immediately. Lines containing */15 need to be manually rewritten in the crontab before migrating. For every 15 minutes, either switch to launchd's StartInterval (interval specified in seconds), or write out the fixed values 00,15,30,45 as an array of four entries.
<key>StartCalendarInterval</key>
<array>
<dict><key>Minute</key><integer>0</integer></dict>
<dict><key>Minute</key><integer>15</integer></dict>
<dict><key>Minute</key><integer>30</integer></dict>
<dict><key>Minute</key><integer>45</integer></dict>
</array>
Or specifying seconds with StartInterval is simpler:
<key>StartInterval</key>
<integer>900</integer>
900 seconds = 15 minutes. This form is outside the script's auto-generation scope, but it's a single hand-written spot.
Stuck 2: ~ Paths in the crontab Caused Label Collisions and Overwrote Old plists
Symptom. Inside ~/.claude/scripts/launchd-proposed/, which I was checking in dry mode, plists with unfamiliar label names had appeared. Names like com.shun.daily-brief-08.plist and com.shun.note-publish-308.plist โ with a time appended to the end.
Cause. Because the crontab was written with ~ as ~/.claude/scripts/daily-brief.sh, it didn't hit the absolute-path match ~/.claude/scripts/[^ ]+ on line 35 and fell into the fallback command-name-minutehour form. On top of that, the com.shun.daily-brief.plist generated by a previous apply was still sitting in ~/Library/LaunchAgents/, so the old plist and the new plist existed in duplicate under different labels.
Running launchctl list | grep com.shun showed two entries calling the same script.
Fix. Open the crontab with crontab -e and rewrite ~ as an absolute path. Then manually unload and delete the old-label plist in ~/Library/LaunchAgents/.
launchctl unload ~/Library/LaunchAgents/com.shun.daily-brief-08.plist
rm ~/Library/LaunchAgents/com.shun.daily-brief-08.plist
You need the habit of always running dry before apply to visually confirm the generated labels and check they're in the expected com.shun.<script-name> form. If fallback-form names (trailing digits) are mixed in, suspect how the crontab is written.
Stuck 3: node and python Were command not found
Symptom. After apply, the same error kept appearing every time in ~/.claude/logs/com.shun.note-autolike.log.
/bin/zsh: node: command not found
Running the same command manually from the terminal works fine.
Cause. The generated plist doesn't include EnvironmentVariables. Even started via /bin/zsh -c, .zshrc isn't read, and the ~/.nvm/versions/node/v24.13.0/bin that nvm adds isn't in PATH. Your terminal's shell session and processes under launchd management run in completely different PATH environments.
Fix. Manually edit the generated plist and add EnvironmentVariables before <key>ProgramArguments</key>. daily-brief.plist (quoted from the real file) is the correct model:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>
Properly, this block should be built into the script's generation template. But "which node version to use" varies by environment, and hardcoding it into the template means rewriting every plist when the environment changes. Perhaps the current script omits it deliberately to avoid that "danger of pinning a version" โ at least, that's how I've interpreted it to make peace with it.
In actual operation, I always hand-add EnvironmentVariables to the plists of jobs that use node. The division of labor is: script generation "builds 90% of the skeleton," and the remaining 10% โ PATH injection โ is manual.
Stuck 4: cron + launchd Were Double-Running the Same Script
Symptom. note auto-posting was supposed to run twice a day, but the logs showed the posting API being called four times a day. It came to light when I hit the rate limit and error responses started appearing.
Cause. I'd forgotten to delete the cron lines with crontab -e after apply. I'd overlooked the warning at the end of the script (lines 94โ95).
๐จ cron ่กใฏ **ๆๅใงๅ้คใใฆใใ ใใ**: crontab -e
(่ชคใฃใฆ cron+launchd ไธกๆน่ตฐใใฎใ้ฟใใใใ)
I'd convinced myself that "cron lines are safe to leave" because the cron daemon doesn't start in a macOS Sequoia environment. In reality, even on Sequoia there are moments when the cron daemon restarts (mainly after OS updates), and at that point both start running. This time, a macOS minor update was that moment.
Fix. Check which lines have been migrated to launchd with crontab -l and either delete them all or comment out the migrated ones. The safest is crontab -r (delete everything), but if anything hasn't been migrated there's no way back, so I handled it with crontab -e, checking line by line.
Since that failure, I run these two commands as a set to confirm apply is complete.
# launchdๅดใฎ็จผๅ็ขบ่ช
launchctl list | grep com.shun
# cronๅดใฎๆฎ้ชธ็ขบ่ช๏ผ0่กใชใOK๏ผ
crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -v '^$' | wc -l
If the second command returns 0, no active cron lines exist. That's my criterion for judging the migration complete.
Stuck 5: The plist I Checked in dry Was a Different File From the One apply Deployed
Symptom. Eyeball the output in dry โ no problems โ run apply โ and somehow the schedule has changed.
Cause. Old plists from a previous dry were still sitting in the PROPOSED directory (~/.claude/scripts/launchd-proposed/). This time's dry generated from a different set of cron lines, so updated plists and old plists were mixed together. Since apply deploys all of PROPOSED/*.plist, unintended older-generation plists also got copied over into ~/Library/LaunchAgents/.
for f in "$PROPOSED"/*.plist; do
cp "$f" "$TARGET_DIR/"
This copy-everything is the origin of the problem.
Fix. Make it a habit to clear the PROPOSED directory before dry.
rm -f ~/.claude/scripts/launchd-proposed/*.plist
~/.claude/scripts/cron-to-launchd.sh dry
Or check the diff between PROPOSED and LaunchAgents with diff right before apply. Both are chores, but since there's no cleanup handling on the script side, for now manual discipline is the only way to cover it.
To sum up the sticking points so far: the only lines the script automates are the ones that are "fixed schedule, absolute path, no PATH needed." The rest โ */N format, ~ paths, nvm/pyenv dependencies โ need manual pre- or post-processing. Had I understood that boundary up front, I could have prevented three of the four failures. It's more accurate to read the 97-line script not as something that "fully automates cron migration," but as a tool that "skips 80% of the manual work and throws the remaining 20% into relief."
Pitfalls
The "where I got stuck" section above covered five episodes. Here I organize the pitfalls systematically so the same failures don't repeat. First let's confirm "the scope the script can automate," then line up the easily-missed traps all at once.
What Can and Can't Be Automated
cron-to-launchd.sh (97 lines) only works correctly for cron lines that satisfy all of the following conditions.
- The cron expression uses only fixed values โ no
*/Nformat - The command is written with an absolute path โ not a
~expansion - The command string contains no
&,<, or> - The same script is registered at only one time
Lines that fall outside these four conditions either break auto-generation or require mandatory manual fixes after generation. It's accurate to use it not as something that "fully automates migrating every crontab line," but as "a tool that builds 80% of the skeleton for lines meeting the four conditions and throws the remaining 20% of manual work into relief."
Pitfall List (With Real Code)
XML escaping only covers & โ plists break on lines containing < and >
Look at line 65 of the script.
<string>${cmd//&/&}</string>
It converts & to &, but there's no conversion for < โ < or > โ >. If your crontab has a line with a redirect like cmd > /dev/null 2>&1, a > gets mixed into the <string> tag of the generated plist and the XML parser can't read the plist. launchctl load returns an error, but since the apply loop moves on to the next plist, it's a structure where a single broken file is easy to miss. For lines containing > or <, either move the redirect inside the script before migrating, or hand-write the plist.
Generated plists have no RunAtLoad โ you can't verify behavior right after apply
The auto-generation template (all of lines 54โ76) has no RunAtLoad key. Meanwhile, lines 28โ29 of the hand-finished com.shun.daily-brief.plist real file contain <key>RunAtLoad</key><true/>.
A plist without RunAtLoad doesn't execute until the next scheduled time. Checking the log right after apply and finding nothing written isn't a malfunction โ it's by design. The problem, though, is that you can't test "does this actually work" on the spot. When you want to check, use launchctl kickstart:
launchctl kickstart -k gui/$(id -u)/com.shun.xxx
tail -f ~/.claude/logs/com.shun.xxx.log
StartCalendarInterval is a bare <dict> โ multiple times require manual conversion to <array>
The cal_xml on lines 46โ52 of the generation script is complete with a single <dict>. Expressing "twice, at 8:00 and 10:30" like com.shun.daily-brief.plist (lines 33โ47 of the real file) requires an array, but the script doesn't generate arrays.
<!-- ่ชๅ็ๆ็ฉ๏ผๅไธๆๅปใใ่กจ็พใงใใชใ๏ผ -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>8</integer>
<key>Minute</key><integer>0</integer>
</dict>
If you want to assign multiple times to the same script, manually rewrite the plist into array form after generation.
Registering the same script at multiple times in the crontab makes the later plist overwrite the earlier one
Suppose you want daily-brief.sh to run at 8:00 and 10:30, so you write two lines in the crontab.
0 8 * * * /path/to/.claude/scripts/daily-brief.sh
30 10 * * * /path/to/.claude/scripts/daily-brief.sh
Because label generation (line 40) strips the extension from the script name, both lines become com.shun.daily-brief. The plist filename is identically com.shun.daily-brief.plist. The line processed later (the 10:30 one) overwrites the earlier one (8:00), and the 8:00 setting disappears. There's no collision detection on the script side. Eyeballing the generated output in dry is the only recourse.
The */N format fails silently โ launchctl load looks successful
The crux of the episode detailed on p2, in one line. */15 gets written out as <integer>*/15</integer> and launchd rejects the plist during internal validation. The launchctl load command returns exit code 0 so it looks successful, but if launchctl list com.shun.xxx can't find the service, it was rejected. Manually converting lines containing */N before migration is the only solution.
~ paths fall into the label-generation fallback
The regex on line 35 only matches absolute paths. If you've written ~/.claude/scripts/note-autolike.sh, the fallback (lines 36โ39) kicks in and the label gets trailing digits, like com.shun.note-autolike-308. If a com.shun.note-autolike.plist generated earlier from an absolute path is still in ~/Library/LaunchAgents/, you've created a double-execution state where two different labels call the same script. Always write absolute paths in the crontab.
Running apply without clearing PROPOSED mixes in old plists
for f in "$PROPOSED"/*.plist on line 87 copies every file in PROPOSED indiscriminately. If a plist generated by a previous dry for a cron line you've since deleted is still there, the job you thought you deleted comes back to life on apply. Make clearing with rm -f ~/.claude/scripts/launchd-proposed/*.plist before running dry a habit.
Generated plists have no EnvironmentVariables โ nvm, pyenv, and Homebrew commands die
The generation template (lines 54โ76) doesn't include the EnvironmentVariables key. Since launchd doesn't read your .zshrc, a script calling nvm-managed node falls over immediately at startup with node: command not found. It works fine when run manually from the terminal but dies via launchd โ that asymmetry makes diagnosis hard. Using the PATH string on lines 6โ9 of com.shun.daily-brief.plist as your model, add it to every plist that uses node or python.
Generated plists have no LowPriorityIO or Nice โ automation interferes with the foreground
Lines 12โ15 of com.shun.daily-brief.plist have LowPriorityIO and Nice 10, but the generation template doesn't. Without the setting, background jobs run at normal I/O priority. If you've ever had a job doing heavy file reads and writes slow down your editor or browser's responsiveness, check whether these keys are present.
Forgetting to delete cron lines is a time bomb โ the next OS update double-runs everything
After the script's apply (lines 94โ95) it only warns "please delete the cron lines manually"; the deletion isn't automated. Since the cron daemon doesn't start on Sequoia, it's easy to think "leaving them is safe," but there are real cases where a macOS minor update revives the cron daemon. My note auto-posting running four times a day and hitting the API rate limit came out of this failure. I prevent recurrence by including "zero cron leftovers" in the criteria for migration completion.
Best Practices
A rule set distilled from a 97-line script and six months of operation, usable for both migration work and day-to-day operation.
1. Write crontab entries with absolute paths
Write /home/.../.claude/scripts/xxx.sh instead of ~/.claude/scripts/xxx.sh. It matches the regex on line 35 and the label becomes the intended com.shun.xxx. Rewriting past cron lines takes effort, but it prevents three things at once: label collisions, double execution, and confusion from fallback naming after migration.
2. Manually convert the */N format before migrating
*/15 * * * * (every 15 minutes) converts to one of two things. If the interval is fixed, StartInterval (in seconds) is simplest.
<key>StartInterval</key>
<integer>900</integer> <!-- 900็ง = 15ๅ -->
If you need execution at specific minutes, enumerate fixed values in an array (minutes 0, 15, 30, 45). Missed conversions can be caught with plutil -lint.
3. Clear the PROPOSED directory before dry
rm -f ~/.claude/scripts/launchd-proposed/*.plist
~/.claude/scripts/cron-to-launchd.sh dry
Running these two lines as a set prevents the problem of older-generation plists getting mixed into apply.
4. Validate every plist with plutil -lint after dry, before apply
for f in ~/.claude/scripts/launchd-proposed/*.plist; do
echo "--- $(basename $f)"
plutil -lint "$f"
done
Catch */N contamination, missed XML escaping, and syntax errors up front with Apple's official tool. Don't apply any plist for which plutil -lint doesn't return OK.
5. Confirm completion with two commands right after apply
# launchdๅดใฎ็จผๅ็ขบ่ช
launchctl list | grep com.shun
# cronๆฎ้ชธ็ขบ่ช๏ผ0ใชใOK๏ผ
crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -v '^$' | wc -l
If the second line returns 0 and the launchd entry count matches the number of lines targeted for migration, you can judge the migration complete.
6. Manually add EnvironmentVariables to the plists of jobs that use node
Insert it immediately before <key>ProgramArguments</key> right after generation:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>
Match the nvm version number to your actual environment. Lines 6โ9 of com.shun.daily-brief.plist are the model.
7. Rewrite StartCalendarInterval as an array for multi-time plists
If you want to run the same script at two times, use an array in a single plist (don't write two crontab lines and cause a label collision).
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>30</integer></dict>
</array>
The description on lines 33โ47 of com.shun.daily-brief.plist is a live example.
8. Set LowPriorityIO and Nice 10 on background jobs generally
Add it to every generated plist so it doesn't get in the way of your work:
<key>LowPriorityIO</key><true/>
<key>Nice</key><integer>10</integer>
By having the automation environment "erase its presence," you can design it so it doesn't encroach on human working territory.
9. Hand-write plists for lines whose commands contain &, <, or >
Don't rely on generation; do the XML escaping accurately:
-
&โ& -
<โ< -
>โ>
Moving redirects inside the shell script being called is cleanest. Try to handle redirects within the plist's XML and you'll almost always hit this escaping problem.
10. Periodically check LastExitStatus with launchctl list com.shun.xxx
launchctl list com.shun.daily-brief
"LastExitStatus" = 0 is healthy. Anything other than 0, check the log. Weekly bulk check:
launchctl list | grep com.shun | awk '{print $3}' | \
xargs -I{} sh -c 'launchctl list "{}" 2>/dev/null' | \
grep -E '"Label"|"LastExitStatus"'
11. Debug with on-demand execution via launchctl kickstart
When you want immediate execution without waiting for the scheduled time:
launchctl kickstart -k gui/$(id -u)/com.shun.xxx
-k is an idempotent option that kills the running instance and restarts it. If nothing appears in the log, it's a PATH problem or a script path problem.
12. Verify every service is alive after a macOS update
Minor updates can change launchd's behavior. If the daily brief doesn't arrive the morning after an update, hit launchctl list | grep com.shun first. If a service is gone, re-apply brings it back.
13. Define three "completion conditions" for the migration
When the "end" of migration work is vague, you tend to skip verification. I set the following as completion conditions:
- The entry count of
launchctl list | grep com.shunmatches the number of cron lines targeted for migration -
crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -v '^$' | wc -lreturns0 - Every service's
LastExitStatusis0, at least after its first run
Only when all three are satisfied can you say "migration complete."
14. Estimate the total time for the migration work up front
Count the number of cron lines, how many contain the */N format, and how many jobs depend on nvm before you start. With ten or fewer, the whole sequence of dry โ plutil validation โ manual fixes โ apply โ completion check finishes within 90 minutes. With 30 or more, a split strategy is realistic: auto-migrate the lines meeting the four conditions first, then hand-migrate the rest on later days.
Summary
The problem of macOS's cron daemon quietly stopping is slow to discover precisely because no error appears. Entries are lined up in crontab -l, yet the 8:00 daily brief doesn't arrive and the 11:00 social post doesn't go out โ and it takes hours before that odd feeling registers. It's more accurate to frame migrating to launchd not as "dealing with it after cron breaks," but as "an up-front investment in getting back onto macOS's native mechanism."
What the 97-line cron-to-launchd.sh does is simple. Read the crontab line by line, convert five fields into XML, write it out as a plist. In three steps โ dry โ plutil validation โ apply โ you can mass-produce skeletons for lines that are fixed-schedule, absolute-path, and PATH-free. But it's not "fully automatic magic." The */N format, ~ paths, nvm/pyenv dependencies, multiple times, characters requiring XML escaping โ these need manual pre- or post-processing. By having the script "build 90% of the skeleton," the target of the manual work becomes clear. Understanding that structure and using it accordingly is the shortest path to not getting stuck after migration.
For jobs you've finished moving to launchd, you can check state instantly with launchctl list com.shun.xxx. LastExitStatus being 0 proves "it is running," not "it should be running." The reliability of an autonomous environment accumulates by eliminating the discovery that "I thought it was running, but it had stopped."
I've written up the full picture of the setup, the ยฅ1.2M/month breakdown, 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*












