# The Morning My cron Jobs Went Silent: A 97-Line Script That Migrated Everything to launchd

> Source: <https://dev.to/bokuwalily/the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything-to-launchd-4aia>
> Published: 2026-09-01 00:00:04+00:00

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.

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.

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.

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.

```
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 から手動削除しないと二重起動
```

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//&/&amp;}</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**.

`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.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.

`*/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.

`/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.

`*/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.

`~`

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.

`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.

**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.

`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."

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.

`cron-to-launchd.sh`

(97 lines) only works correctly for cron lines that satisfy all of the following conditions.

`*/N`

format`~`

expansion`&`

, `<`

, or `>`

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."

**XML escaping only covers & — plists break on lines containing < and >**

Look at line 65 of the script.

```
<string>${cmd//&/&amp;}</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.

``` php
<!-- 自動生成物（単一時刻しか表現できない） -->
<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.

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.

``` php
<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:

`launchctl list | grep com.shun`

matches the number of cron lines targeted for migration`crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -v '^$' | wc -l`

returns `0`

`LastExitStatus`

is `0`

, at least after its first runOnly 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.

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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
