cd /news/developer-tools/editing-the-plist-changed-nothing-la… · home topics developer-tools article
[ARTICLE · art-115067] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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.

read29 min views1 publishedAug 29, 2026

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:

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

[ "$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:

[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:

[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):

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

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

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:

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

[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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート

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

Follow along: Portfolio · X · GitHub*

── more in #developer-tools 4 stories · sorted by recency
── more on @lily 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/editing-the-plist-ch…] indexed:0 read:29min 2026-08-29 ·