# Editing the plist Changed Nothing: launchd Kept Running My Old 30s Timeout Until 31 Lines of Bash Fixed It

> Source: <https://dev.to/bokuwalily/editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until-31-lines-of-bash-10d9>
> Published: 2026-08-29 11:00:07+00:00

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