Editing the plist Changed Nothing: launchd Kept Running My Old 30s Timeout Until 31 Lines of Bash Fixed It Lily, an engineer running a ¥1.2M/month autonomous platform, discovered that editing a launchd plist file did not change the running job's timeout because launchd freezes environment variables at bootstrap time. She fixed the issue with a 31-line shell script that reconciles the plist with the running state, only reloading jobs when they are idle to avoid data loss. I'm Lily. I made ¥100k/month in college, pushed it to ¥600k/month juggling multiple side gigs, lost all of it when I was laid off for reasons that had nothing to do with me, spent six months rebuilding an autonomous Claude Code environment, and now run ¥1.2M/month in revenue. Here's the concrete before/after this article is about: I changed a timeout from 30 to 45 in a plist, and launchd kept running the job at 30 — for as long as the job stayed loaded. The fix is a 31-line shell script that compares the two values and re-syncs only when the job is idle. Say you're running automated social-media likes across several lanes. You want to stretch one lane's timeout from 30 seconds to 45. You open ~/Library/LaunchAgents/com.lily.autolike.lane1.plist in an editor, change AUTOLIKE TIMEOUT SEC under EnvironmentVariables from 30 to 45 , save, and close the window thinking "that should take effect on the next run." The next morning, the logs show timeout errors at exactly the same rate as yesterday. This comes from a fundamental property of how launchd works. macOS launchd reads the plist at the moment it bootstrap s a job, and freezes the environment variables into that job's definition. Rewriting the plist file afterwards changes nothing about the environment variables of a job already loaded in memory. A silent divergence opens up between the plist on the filesystem and the execution definition launchd is holding. You can confirm that divergence by running launchctl print gui/$ id -u /com.lily.autolike.lane1 . If the output still contains a line reading AUTOLIKE TIMEOUT SEC = 30 , launchd is still operating on the old value. Even though the plist file says 45 , the job keeps running with 30 . The fix is simple: unload the job with launchctl bootout and load it again with launchctl bootstrap . But that's exactly where the real problem starts. Think about how much work the auto-like script does in a single run: authenticating to the target account, fetching the feed, liking items one by one, writing out the log when it's done — all of that in order, as one "run." If you fire launchctl bootout in the middle of it, the process is force-killed and every like that run performed is lost. The comment at the top of the script spells this out: 走行中のジョブを bootout するとその run のいいねが丸ごと消えるため、必ず待つ。 "If you bootout a job while it's running, that run's likes are wiped out entirely — so always wait." In an environment where multiple lanes run 24 hours a day, this accumulates. On an automation platform backing ¥1.2M/month in revenue, "I killed one job" is not a joke. Especially late at night when several lanes are running at once, a script that issues bootout without checking anything is a landmine that shreds your own output. But "change the setting, then manually confirm the job is idle before reloading" isn't an autonomous environment either. Check whether it's stopped, and quietly re-sync only the lanes that are stopped — handing that judgment to the script is the design philosophy behind autolike-plist-reconcile.sh . Rebuilding from zero income taught me something. There are two kinds of people making money on the side: people who do the work, and people who build an environment that keeps doing the work. The first kind logs in every day and moves their hands; when their hands stop, the income stops. The second kind sets up the environment once and owns a system that keeps running whether they're there or not. Spending six months building an autonomous Claude Code environment came from exactly that idea. Keep multiple automation jobs resident under launchd, and when you change a setting the environment catches up on its own — having a mechanism where the config files and the running state stay in agreement without human intervention is the core of a ¥1.2M/month autonomous platform. autolike-plist-reconcile.sh is a small, 31-line script, but the "build an environment" mindset is condensed into it. Instead of reloading by hand, the script periodically detects the divergence and repairs it automatically, aiming only at safe moments. That is an environment where "a human has to step in every time a setting changes" no longer holds. The script processes com.lily.autolike. .plist under ~/Library/LaunchAgents/ one file at a time. It skips files with .bak- and .disabled suffixes, and for the remaining files it compares the configured value in the plist against the running value in launchd. ~/Library/LaunchAgents/com.lily.autolike. .plist excluding .bak- / .disabled │ │ ① read the configured value from the plist with PlistBuddy ▼ want=45 ← the target value written in the plist │ │ ② read the value inside launchd with launchctl print ▼ have=30 ← the value launchd is actually holding │ ├─ want == have ──────────── skip no change │ └─ want = have │ │ ③ check the PID with launchctl list │ ├─ pid present running │ └── log a "deferred" line → wait until the next run │ └─ pid absent stopped ├── unload with launchctl bootout ├── reload with launchctl bootstrap └── record "reloaded 30 - 45" in the log PB=/usr/libexec/PlistBuddy want=$ $PB -c "Print :EnvironmentVariables:AUTOLIKE TIMEOUT SEC" "$P" 2 /dev/null || continue PlistBuddy is the standard macOS command for manipulating plists. With -c "Print :EnvironmentVariables:AUTOLIKE TIMEOUT SEC" you can reference a nested key directly. 2 /dev/null throws away errors, and || continue skips plists that don't have that key. This want is "the value that, according to the plist file, ought to be in effect." D="gui/$ id -u " have=$ launchctl print "$D/$L" 2 /dev/null | sed -n 's/. AUTOLIKE TIMEOUT SEC = \ 0-9 \ . /\1/p' | head -1 -n "$have" || continue launchctl print gui/501/com.lily.autolike.lane1 501 being the UID dumps that job's complete information in text form. From that, sed looks for the AUTOLIKE TIMEOUT SEC = 30 pattern and extracts only the numeric part 30 . head -1 prevents multiple matches. If have comes back empty the job itself isn't loaded , || continue skips it. Attempting bootout against a job that isn't loaded makes launchctl return an error. This have is "the value that currently exists in launchd's memory and is actually running." The key point is that it reads launchd's internal state directly rather than the filesystem — the runtime reality you could never discover by reading the plist alone becomes visible for the first time here. php "$want" = "$have" && continue pid=$ launchctl list | awk -v l="$L" '$3==l{print $1}' if -n "$pid" && "$pid" = "-" ; then echo " $ date '+%F %T' $L 実行中 pid=$pid のため見送り $have - $want " "$LOG" continue fi If want == have , no change is needed, so it skips. If there is a difference, it next checks that job's current PID with launchctl list . The output of launchctl list has three columns — PID , LastStatus , Label — and awk pulls the PID from the row whose label matches. Unless the PID is - launchd's conventional notation for "stopped" , the job is considered running and "defer" is chosen. The log records lines in this form: php 2026-08-23 08:30:00 com.lily.autolike.lane1 実行中 pid=12345 のため見送り 30 - 45 "deferred because it's running pid=12345 " So the intent — "I wanted to change this, but I'm waiting for the right timing" — is still legible the next time you check. Since this script is registered with launchd as a periodic job, even a deferred lane gets checked again at the next launch, and re-syncs automatically as soon as there's an opening. launchctl bootout "$D/$L" 2 /dev/null if launchctl bootstrap "$D" "$P" 2 /dev/null; then echo " $ date '+%F %T' $L reloaded $have - $want" "$LOG"; changed=$ changed+1 else echo " $ date '+%F %T' $L RELOAD FAILED" "$LOG" fi Only when the job is confirmed stopped does it run bootout → bootstrap . bootout unloads it once, and bootstrap re-reads the plist and loads it again, which pulls the new value of AUTOLIKE TIMEOUT SEC into launchd's memory. A successful log line looks like this: php 2026-08-23 08:30:05 com.lily.autolike.lane1 reloaded 30 - 45 Putting the before and after values side by side makes "what changed" obvious at a glance. On failure, RELOAD FAILED is left behind and the next run retries. At the end of the script, it records a summary of how many re-syncs happened in that single run. "$changed" -gt 0 && echo " $ date '+%F %T' done changed=$changed" "$LOG" When changed=0 all lanes already in agreement , it writes nothing. With this design, the log file only accumulates records "when something changed," which suppresses day-to-day noise. In an autonomous environment where you read across dozens of log files to understand state, noise-free logs translate directly into debugging speed. Making PATH explicit is handled by a single line at the top of the script. PATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH A script running as a launchd job starts in a minimal execution environment that differs from a normal terminal session. Without an explicit PATH, path resolution for PlistBuddy and launchctl fails, and you sink into the classic swamp of "it works on my machine but not when launchd starts it." Together with set -uo pipefail , this structure blocks unintended behavior from the outset. set -uo pipefail keeps things from breaking The second line of the script is this: set -uo pipefail Without that one line, the script can fall into a state where it "appears to work but does nothing." -u makes references to undefined variables an error. Suppose you typo'd the variable name used to fetch $have somewhere. Without -u , the typo'd variable expands to an empty string, and $have in "$want" = "$have" becomes empty. "want is 45 , have is an empty string" doesn't match, so the comparison is false and it proceeds to the PID check. Naturally that label isn't in launchctl list 's output, so no PID is retrieved, -n "$pid" is false — meaning it misjudges the job as "stopped" and runs bootout → bootstrap . Sometimes nothing breaks and the re-sync just happens, but the possibility always remains that "something being empty" because of a typo'd variable throws off some other piece of logic. -u forces early discovery of this class of bug by making the shell stop with an error. -o pipefail controls the exit code of the pipeline as a whole. Without -o pipefail , a pipeline's exit code is that of the last command. That is, even if the output of launchctl print "$D/$L" 2 /dev/null | sed -n '...' | head -1 is empty, $? is 0 as long as head -1 exits normally exit code 0 . Since this script doesn't use set -e , that isn't an immediate problem — but because the presence or absence of pipefail can change behavior when you modify this script in the future, it's safer to state it explicitly from the start. Writing plain -uo without the -o has the same effect, but writing set -uo pipefail explicitly conveys the intent that "pipefail was configured deliberately." for P in "$HOME"/Library/LaunchAgents/com.lily.autolike. .plist; do case "$P" in .bak- | .disabled continue ;; esac After narrowing target files with a glob pattern, there's a second stage that excludes with case . The reason for using case rather than find options or negated glob syntax is clear. Shell negated globs extglob forms like .bak are poorly portable, and bash and zsh differ in the options needed to enable them. Scripts launched from launchd run under /bin/bash , so unless you explicitly enable extglob with set , you can't use it. Also, when exclusion patterns grow, case only needs another | , which keeps it readable. The .bak- suffix becomes a problem because text editors sometimes leave automatically created backup files inside the LaunchAgents directory. A file like com.lily.autolike.lane1.plist.bak-20260823 doesn't match .plist , but depending on the editor, some create the backup under a different name next to the .plist rather than in .plist.bak form. .disabled is a convention for renaming a job when you want to disable it manually; including this exclusion lets you express "this file isn't in use right now" without resorting to launchctl unload . have=$ launchctl print "$D/$L" 2 /dev/null \ | sed -n 's/. AUTOLIKE TIMEOUT SEC = \ 0-9 \ . /\1/p' \ | head -1 In the output of launchctl print gui/501/com.lily.autolike.lane1 , the part carrying environment variables looks like this 501 being the actual UID : js environment = { AUTOLIKE TIMEOUT SEC = 30 HOME = /Users/... } The sed expression s/. AUTOLIKE TIMEOUT SEC = \ 0-9 \ . /\1/p targets the whole line, swallows everything before and after the key name with . , captures only the numeric part into a capture group with \ 0-9 \ , and prints it as \1 . The combination of the -n flag and p means "print only matching lines," so nothing is printed if the environment variable doesn't exist. Narrowing to the first line with head -1 prevents multiple matches in case a job name or another setting happens to contain a string resembling AUTOLIKE TIMEOUT SEC = . It can't really happen in practice, but defensively taking a single line eliminates the situation where "multiple lines come back, $have becomes multi-line, and every subsequent comparison fails." php pid=$ launchctl list | awk -v l="$L" '$3==l{print $1}' if -n "$pid" && "$pid" = "-" ; then The output of launchctl list has three columns. PID Status Label 12345 0 com.lily.autolike.lane1 - 0 com.lily.autolike.lane2 The first column is the PID: a number when the job is currently running, and - when it's stopped waiting for the next StartInterval . awk -v l="$L" '$3==l{print $1}' prints the first column of the row whose third column matches the label. Testing with only -n "$pid" is not enough. -n tests "not an empty string," so the string - is non-empty and evaluates to true. In other words, you get a state where "a stopped job with PID - is misjudged as running and deferred forever." The additional "$pid" = "-" check prevents that misjudgment. 2 /dev/null on bootout launchctl bootout "$D/$L" 2 /dev/null if launchctl bootstrap "$D" "$P" 2 /dev/null; then The bootout line has neither || exit 1 nor || continue . The design throws away errors with 2 /dev/null , ignores the exit code, and moves on to bootstrap . There's a reason for this. bootout may be called when the target job is already unloaded if another script booted it out first, or right after the job crashed . In that case launchctl bootout returns an error, but the goal is "getting the job into an unloaded state," and if it's already unloaded, that's equivalent to success. Stopping on the error means the following bootstrap never runs and the whole re-sync fails. Discarding errors with 2 /dev/null and unconditionally proceeding to bootstrap secures idempotence. bootstrap , on the other hand, has its success checked with if . A bootstrap failure — a syntax error in the plist file, insufficient permissions — means the abnormal state "the re-sync failed," so the design leaves RELOAD FAILED in the log and retries on the next run. changed=0 ... ループ内で changed=$ changed+1 ... "$changed" -gt 0 && echo " $ date '+%F %T' done changed=$changed" "$LOG" When changed is 0, no summary line is printed. That's intentional. This script is registered as a periodic launchd job. All lanes matching on want == have is the normal state, and if every normal run left one line in the log, you'd have hundreds of lines within a few days. Designing it so "a record is left only when something is wrong" means that the moment you open the log file, only "the timestamp and content of the changes" enters your eyes. "Deferred," meanwhile, is logged every time the loop comes around. php echo " $ date '+%F %T' $L 実行中 pid=$pid のため見送り $have - $want " "$LOG" That's deliberate. When you're debugging "why hasn't my change taken effect," several consecutive deferral lines immediately tell you "there's a running job and it's waiting for an opening." If a reloaded line eventually appears, you can confirm it completed normally; if RELOAD FAILED arrives, you learn about the anomaly. Because log density translates directly into debugging speed, the design is neither "always verbose" nor "always silent" but "record only changes and waits." When I first wrote the script, the PID check looked like this: php pid=$ launchctl list | awk -v l="$L" '$3==l{print $1}' if -n "$pid" ; then echo "実行中のため見送り" continue fi The judgment is "if the PID isn't empty, it's running." I registered this and ran it for a day, and the log was nothing but an endless stream of "deferred." php 2026-08-10 01:00:00 com.lily.autolike.lane2 実行中 pid=- のため見送り 30 - 45 2026-08-10 02:00:00 com.lily.autolike.lane2 実行中 pid=- のため見送り 30 - 45 2026-08-10 03:00:00 com.lily.autolike.lane2 実行中 pid=- のため見送り 30 - 45 Seeing the output pid=- is what tipped me off. For a stopped job, launchctl list doesn't leave the PID column empty — it puts in the single character - . Since - isn't an empty string, -n "$pid" is always true, and no matter how long you wait it keeps being judged "running." The fix is a single added line. if -n "$pid" && "$pid" = "-" ; then Now it judges "running" only when a PID exists and it isn't the - that indicates stopped. This is a pit you're guaranteed to fall into if you don't know launchd's conventional notation, and man launchctl only mentions it in passing. I only noticed after looking at actual launchctl list output with my own eyes. When I ran the script manually from the terminal, it worked perfectly. Register the same script with launchd for periodic execution, and the log file was never updated at all. Not only was the changed counter not moving, not even deferral lines were coming out. Because of set -uo pipefail , it had to be stopping on an error. Adding exec /tmp/debug.log 2 &1 at the top of the script to capture debug output for the experiment produced this error: /bin/bash: /usr/libexec/PlistBuddy: No such file or directory PlistBuddy uses the absolute path /usr/libexec/PlistBuddy , yet it wasn't found — the cause was that despite line 14 specifying the absolute path with PB=/usr/libexec/PlistBuddy , the expansion of $PB was coming out empty for some reason. The -u option turned the empty-variable reference into an error, and the for loop terminated in a way closer to exit than continue . Digging in, I found leftover traces before PB=/usr/libexec/PlistBuddy of an attempt to use PlistBuddy through a different variable commented out, but actually causing a separate problem because of -u . The variable reference had been broken during cleanup — that's the precise cause. But the essential problem I discovered in parallel was PATH. A shell script launched from launchd inherits none of the PATH set up by a normal terminal session things like /opt/homebrew/bin added in .zshrc or .zprofile . Without explicitly setting PATH=/usr/bin:/bin:/usr/sbin:/sbin , the commands you use inside the script end up in the "works locally, doesn't work under launchd" state. Since PlistBuddy lives in /usr/libexec/ , the absolute-path form PB=/usr/libexec/PlistBuddy is fine, but launchctl , sed , awk , and date are all resolved via PATH. /usr/bin:/bin:/usr/sbin:/sbin is standard in launchd's minimal environment, but in some environments it can be even narrower. The explicit PATH on line 2 is written as "insurance against ever stepping on this problem twice." PATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH launchctl print output came back empty and every lane was skipped One morning I hit a situation where have was empty for every lane, so the script skipped everything. Nothing was in the log, so the state was "exiting normally but skipping." Running launchctl print gui/501/com.lily.autolike.lane1 directly in the terminal returned an error. Could not find service "com.lily.autolike.lane1" in domain for port For a job not loaded into launchd, launchctl print returns an error rather than an empty string. Since 2 /dev/null discards the error, have becomes empty and -n "$have" || continue skips it. Tracing the cause back, I found that during some other configuration work the night before I had mistakenly run launchctl bootout against several lanes and reached morning having forgotten the bootstrap . When the jobs themselves don't exist, autolike-plist-reconcile.sh can do nothing. A feature for "raising an alert when it detects an unloaded job" is outside this script's scope. That's a limit of the design, not a bug. This script's responsibility is "fixing divergence in the environment variables of loaded jobs," and recovery when a job doesn't exist is handled by a different mechanism. Clearly separating what the script does from what it doesn't cuts off the temptation for one routine to bloat and solve several problems in one piece of code. During a period when I was editing plists directly in a text editor, the editor was creating automatic backups inside ~/Library/LaunchAgents/ , in a form like com.lily.autolike.lane1.plist~ ~ being the backup suffix some editors use . That file doesn't match the glob .plist , so it was harmless — but a file left in the form com.lily.autolike.lane1.plist.bak-20260810 did match the glob. Because the backup file also starts with com.lily.autolike. and ends with .plist , the script treats it as a processing target. Read the backup's want with PlistBuddy , read the job's have with launchctl print , and — if they naturally agree — skip. That flow is fine, but when the backup file's contents held an older value, the backup's want differed from the running job's have and an unnecessary bootout → bootstrap fired. The exclusion pattern case "$P" in .bak- | .disabled continue ;; esac was added from that experience. It's tuned to the editor's automatic backup naming convention with the .bak- pattern with the hyphen, because plain .bak risks colliding with other cases . In an environment that has a backup strategy, crushing this kind of side effect in advance is directly tied to the stability of an autonomous environment. Here's a complete rundown of the traps I actually stepped on while wiring a launchd × plist re-sync script into an autonomous environment. On top of the four detailed above misjudging PID - , unset PATH, empty launchctl print , backup files sneaking in , I've organized the patterns that keep catching you during the scaling phase. /usr/bin:/bin:/usr/sbin:/sbin . command not found when launched by launchd. Nothing starts until you write PATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH on the first line. /usr/libexec/PlistBuddy PB=/usr/libexec/PlistBuddy . Trying to add it to PATH is pointless because /usr/libexec is outside the default PATH. exec permission. ProgramArguments , but if you forget chmod +x it dies instantly with Permission denied . Nothing is left in the log either, so diagnosis is slow. Program and ProgramArguments 0 . Program without knowing that ProgramArguments 0 is then treated as argv 0 , and write both, your own script — not /bin/bash — gets passed directly and is treated as a syntax error. awk -v l="$L" used to filter launchctl list takes its value from $L=$ basename "$P" .plist , so if the filename and Label don't match, no PID is retrieved, it always misjudges "stopped," and an unnecessary bootout fires. com.lily.autolike.test.plist for verification, it's processed just like production. Unless you follow the convention of excluding it with a .disabled suffix, a test configuration can overwrite the production environment. launchctl list prints the Label itself, so uppercase notation that differs from the filename makes the awk match fail. Standardize the naming convention so everything after com.lily.autolike. is all lowercase, dot-separated. environment = { changed and the sed pattern stopped matching. Because sed -n 's/. AUTOLIKE TIMEOUT SEC = \ 0-9 \ . /\1/p' is designed so the . swallows both sides, it tolerates most indentation changes — but if the number of spaces around the key name changes, the sed pattern needs revisiting. launchctl list . Starting label names with com.lily. makes real damage unlikely, but the premise is using exact matching $3==l so awk doesn't react to unexpected labels. launchctl print wrong. gui/$ id -u /com.lily.autolike.lane1 is correct, but writing user/$ id -u /… points at a different domain. Making D="gui/$ id -u " a variable managed in one place erases the risk of a typo propagating across the whole script. AUTOLIKE TIMEOUT SEC in multiple places in the script. \ 0-9 \ fails to match when a non-integer value is used. AUTOLIKE TIMEOUT SEC is written with a unit, like 45s , 0-9 comes out empty. The value types diverge between PlistBuddy's want and sed's have, they're always judged different as separate strings, and bootout fires every single time. Manage the value as a pure integer. EnvironmentVariables key doesn't exist in the plist. || continue in $PB -c "Print :EnvironmentVariables:AUTOLIKE TIMEOUT SEC" "$P" 2 /dev/null || continue handles this case, but if you remove 2 /dev/null while debugging, a "Does Not Exist" error appears and reveals the cause. Since it's designed to stay silent, note that nothing is left in the log even when everything gets skipped. LOG="$HOME/.claude/logs/autolike-plist-reconcile.log" ~/.claude/logs/ hasn't been created, echo … "$LOG" errors and the script dies from the write error rather than from set -uo pipefail 's -u . Run mkdir -p ~/.claude/logs before registering the plist. changed=0 for "normal." want == have , but the case where have is empty jobs not loaded and everything is skipped also produces changed=0 with no log output. Periodically confirm through a separate channel — launchctl list | grep com.lily.autolike — that all lanes are loaded. launchctl list while the actual process no longer exists. In that case bootout succeeds but no trace of "stopping something that was running" is left in the log, which makes later debugging harder. - only for a short window. As long as the reconcile script itself runs periodically at a short interval say, 5 minutes , it catches the next opportunity. Conversely, if that job's execution time is comparable to its StartInterval, it's effectively always running and an opportunity to re-sync never arrives.Here are the rules I organized from real operation, the ones where I thought "I should have done this from the start." I've written out the design philosophy packed into 31 lines in a reproducible form. ① Pin PATH on the first line PATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH The biggest trap is verifying in the terminal and assuming you're done. Execution via launchd carries only a minimal PATH environment and inherits nothing from .zshrc or .zprofile . Pinning PATH on the leading line structurally eliminates the "works locally, doesn't work under launchd" problem. ② Always write set -uo pipefail set -uo pipefail -u turns undefined variables into immediate errors, and pipefail propagates mid-pipeline failures into the exit code. Without this one line, a typo'd variable name or a failed intermediate command passes through "as if nothing happened," and processing continues in a wrong state. Write it without exception as the safety device of a shell script. ③ Put PlistBuddy in a variable as an absolute path PB=/usr/libexec/PlistBuddy Since it can't be resolved via PATH, keep it in a variable at the top of the script. Even when used in several places, unifying on a single variable reference means a one-line fix if the path ever changes. ④ Put the launchd domain specification in a variable D="gui/$ id -u " Writing the gui/UID form inline every time raises typo risk. Consolidating it into one variable means launchctl print "$D/$L" , launchctl bootout "$D/$L" , and launchctl bootstrap "$D" "$P" all follow from a single change. ⑤ Manage exclusion patterns with case case "$P" in .bak- | .disabled continue ;; esac Rather than relying on extglob or negated globs, excluding with case is the most portable design. When exclusion patterns grow, you just append with | . Matching the editor's backup naming convention with .bak- with the hyphen prevents mistaken matches on plain .bak . ⑥ Make "skip on failure" explicit when fetching want and have want=$ $PB -c "Print :EnvironmentVariables:AUTOLIKE TIMEOUT SEC" "$P" 2 /dev/null || continue have=$ launchctl print "$D/$L" 2 /dev/null | sed -n '...' | head -1 -n "$have" || continue A failed want fetch a plist without the key skips immediately via || continue . A failed have fetch job not loaded skips via the empty-string check. The reason the two failure patterns are handled by different techniques is that PlistBuddy returns a non-zero exit code on failure, whereas the launchctl print pipe can exit 0 as a whole. ⑦ Explicitly judge a PID of - as "stopped" if -n "$pid" && "$pid" = "-" ; then launchctl list puts the string - , not a numeric PID, for stopped jobs. The -n test alone misjudges - as "has a PID" and defers forever. Always add "$pid" = "-" . Since it isn't spelled out in man launchctl , this is a pit you can't notice until you look at real output with your own eyes. ⑧ Make bootout idempotent; check success only for bootstrap launchctl bootout "$D/$L" 2 /dev/null if launchctl bootstrap "$D" "$P" 2 /dev/null; then …; changed=$ changed+1 else echo "… RELOAD FAILED" "$LOG" fi bootout gets called even when the job is already unloaded. Stopping on the error means the following bootstrap never runs and the whole re-sync fails. Discard errors with 2 /dev/null and proceed unconditionally to bootstrap to secure idempotence. Log only bootstrap 's success or failure and let the next run retry. ⑨ Log only changes and waits 見送り → 毎回記録する(デバッグのため) echo " $ date '+%F %T' $L 実行中 pid=$pid のため見送り $have - $want " "$LOG" 変更なし → 記録しない 変更あり → 記録する "$changed" -gt 0 && echo " $ date '+%F %T' done changed=$changed" "$LOG" The comments read: "deferred → record every time for debugging ", "no change → don't record", "changed → record". Make fully normal runs silent. Keep deferrals, because they're needed to debug "I changed it but it isn't taking effect." reloaded and RELOAD FAILED are state changes, so always keep them. These three tiers let you judge "did anything happen" in one line the moment you open the log. ⑩ Narrow the script's responsibility to one thing All this script does is "fix divergence in the environment variables of loaded jobs." It has no features for "load the job automatically if it isn't loaded," "send a Slack notification after repeated failures," or "compress and rotate logs." Narrowing responsibility to one thing makes it easier to test and easier to combine with other scripts. If you need alerts, split them into a separate script. ⑪ Manage the monitored key name in one place The key name AUTOLIKE TIMEOUT SEC appears in two places: the PlistBuddy command and the sed pattern. In case you add or change monitored targets in the future, defining it as a variable near the top improves maintainability. KEY=AUTOLIKE TIMEOUT SEC want=$ $PB -c "Print :EnvironmentVariables:$KEY" "$P" 2 /dev/null || continue have=$ launchctl print "$D/$L" 2 /dev/null | sed -n "s/. $KEY = \\ 0-9 \\ . /\\1/p" | head -1 The current 31-line script is self-contained as a single-file, single-key tool so it isn't variabilized, but if you monitor multiple environment variables you expand it into a loop. ⑫ Derive the periodic interval from the reflection delay you can tolerate The StartInterval you use when registering the reconcile script itself with launchd becomes "the maximum wait between rewriting the plist and the environment variable actually taking effect." If you expect reflection within 5 minutes, register with StartInterval 300 . However, because there are cases where a running job is deferred, the actual reflection delay is bounded by "the reconcile interval + the job's maximum execution time." ⑬ Don't point the log file at the same place as launchd's StandardOutPath launchd's StandardOutPath and StandardErrorPath are stdout/stderr redirection targets specified in the plist. Manage the reconcile script's own application log the ~/.claude/logs/autolike-plist-reconcile.log pointed at by the LOG variable separately from those. If both point at the same file, launchd's system messages and the application log get mixed together and analysis becomes difficult. ⑭ Test-run with bash -x When verifying behavior before registering with launchd, run it with bash -x so commands are traced. bash -x ~/.claude/scripts/autolike-plist-reconcile.sh Because each line's actual expanded values are printed with a + prefix, you can visually confirm whether variables expand as expected and whether the output of launchctl print is being passed properly to sed . Verifying behavior locally before registering with the plist heads off silent bugs the state where nothing is logged and nothing happens . ⑮ End with an explicit exit 0 exit 0 Putting exit 0 at the end of the script guarantees a normal exit code back to launchd. Combined with settings like KeepAlive , it prevents cases where a non-zero exit steers the job into an unexpected restart loop. If the last command is of the form "$changed" -gt 0 && … , the whole && returns false exit code 1 when the condition is false, so the explicit exit 0 is necessary. In one sentence, what autolike-plist-reconcile.sh does is: "periodically compare the values in launchd's memory against the plist on the filesystem, and quietly re-sync only when the job is stopped." Concretely, four steps: want with PlistBuddy have with launchctl print launchctl list - stopped , re-sync with bootout → bootstrap ; if the PID is a number running , defer and just leave a log lineThis "defer while running, wait for an opening" design lets you apply configuration changes without interrupting running automation jobs. You don't have to time the reload by hand — register the reconcile script itself with launchd and it's handled automatically at the next stopping point. The point is the cleanliness of the design. All the 31-line script takes on is "fixing divergence in loaded jobs" — it doesn't handle job startup, alerts, or log rotation. One script, one responsibility is the basis of an autonomous environment. In an environment where multiple jobs run 24 hours a day, "build something that doesn't break" has a dramatically lower long-term maintenance cost than "notice it when it breaks." A ¥1.2M/month autonomous platform is built out of an accumulation of these "unglamorous but reliably functional 31 lines." Not a single flashy feature. Understand launchd's specification precisely, crush the failure patterns first, design the logs carefully — repeating that is what creates an environment that keeps running without a human present. One question for you: how long is your own tolerable reflection delay between "I edited a config file" and "the running job is actually using it" — and do you have anything closing that gap automatically today? I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and a 30-day walkthrough 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