{"slug": "editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until", "title": "Editing the plist Changed Nothing: launchd Kept Running My Old 30s Timeout Until 31 Lines of Bash Fixed It", "summary": "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.", "body_md": "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.\n\nHere's the concrete before/after this article is about: I changed a timeout from `30`\n\nto `45`\n\nin a plist, and launchd kept running the job at `30`\n\n— 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.\n\nSay 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`\n\nin an editor, change `AUTOLIKE_TIMEOUT_SEC`\n\nunder `EnvironmentVariables`\n\nfrom `30`\n\nto `45`\n\n, save, and close the window thinking \"that should take effect on the next run.\"\n\nThe next morning, the logs show timeout errors at exactly the same rate as yesterday.\n\nThis comes from a fundamental property of how launchd works. macOS launchd reads the plist at the moment it `bootstrap`\n\ns 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.\n\nYou can confirm that divergence by running `launchctl print gui/$(id -u)/com.lily.autolike.lane1`\n\n. If the output still contains a line reading `AUTOLIKE_TIMEOUT_SEC => 30`\n\n, launchd is still operating on the old value. Even though the plist file says `45`\n\n, the job keeps running with `30`\n\n.\n\nThe fix is simple: unload the job with `launchctl bootout`\n\nand load it again with `launchctl bootstrap`\n\n. But that's exactly where the real problem starts.\n\nThink 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`\n\nin 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:\n\n```\n# 走行中のジョブを bootout するとその run のいいねが丸ごと消えるため、必ず待つ。\n```\n\n(*\"If you bootout a job while it's running, that run's likes are wiped out entirely — so always wait.\"*)\n\nIn 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`\n\nwithout checking anything is a landmine that shreds your own output.\n\nBut \"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`\n\n.\n\nRebuilding 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.\n\nThe 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.\n\n`autolike-plist-reconcile.sh`\n\nis 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.\n\nThe script processes `com.lily.autolike.*.plist`\n\nunder `~/Library/LaunchAgents/`\n\none file at a time. It skips files with `.bak-*`\n\nand `.disabled*`\n\nsuffixes, and for the remaining files it compares the configured value in the plist against the running value in launchd.\n\n```\n~/Library/LaunchAgents/com.lily.autolike.*.plist\n  (excluding .bak-* / .disabled*)\n          │\n          │ ① read the configured value from the plist with PlistBuddy\n          ▼\n      want=45  ← the target value written in the plist\n          │\n          │ ② read the value inside launchd with launchctl print\n          ▼\n      have=30  ← the value launchd is actually holding\n          │\n          ├─ want == have ──────────── skip (no change)\n          │\n          └─ want != have\n                │\n                │ ③ check the PID with launchctl list\n                │\n                ├─ pid present (running)\n                │     └── log a \"deferred\" line → wait until the next run\n                │\n                └─ pid absent (stopped)\n                      ├── unload with launchctl bootout\n                      ├── reload with launchctl bootstrap\n                      └── record \"reloaded 30 -> 45\" in the log\nPB=/usr/libexec/PlistBuddy\nwant=$($PB -c \"Print :EnvironmentVariables:AUTOLIKE_TIMEOUT_SEC\" \"$P\" 2>/dev/null) || continue\n```\n\n`PlistBuddy`\n\nis the standard macOS command for manipulating plists. With `-c \"Print :EnvironmentVariables:AUTOLIKE_TIMEOUT_SEC\"`\n\nyou can reference a nested key directly. `2>/dev/null`\n\nthrows away errors, and `|| continue`\n\nskips plists that don't have that key. This `want`\n\nis \"the value that, according to the plist file, ought to be in effect.\"\n\n```\nD=\"gui/$(id -u)\"\nhave=$(launchctl print \"$D/$L\" 2>/dev/null | sed -n 's/.*AUTOLIKE_TIMEOUT_SEC => \\([0-9]*\\).*/\\1/p' | head -1)\n[ -n \"$have\" ] || continue\n```\n\n`launchctl print gui/501/com.lily.autolike.lane1`\n\n(501 being the UID) dumps that job's complete information in text form. From that, `sed`\n\nlooks for the `AUTOLIKE_TIMEOUT_SEC => 30`\n\npattern and extracts only the numeric part (`30`\n\n). `head -1`\n\nprevents multiple matches.\n\nIf `have`\n\ncomes back empty (the job itself isn't loaded), `|| continue`\n\nskips it. Attempting `bootout`\n\nagainst a job that isn't loaded makes `launchctl`\n\nreturn an error. This `have`\n\nis \"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.\n\n``` php\n[ \"$want\" = \"$have\" ] && continue\npid=$(launchctl list | awk -v l=\"$L\" '$3==l{print $1}')\nif [ -n \"$pid\" ] && [ \"$pid\" != \"-\" ]; then\n  echo \"[$(date '+%F %T')] $L 実行中(pid=$pid) のため見送り ($have -> $want)\" >>\"$LOG\"\n  continue\nfi\n```\n\nIf `want == have`\n\n, no change is needed, so it skips. If there is a difference, it next checks that job's current PID with `launchctl list`\n\n. The output of `launchctl list`\n\nhas three columns — `PID`\n\n, `LastStatus`\n\n, `Label`\n\n— and `awk`\n\npulls the PID from the row whose label matches. Unless the PID is `-`\n\n(launchd's conventional notation for \"stopped\"), the job is considered running and \"defer\" is chosen.\n\nThe log records lines in this form:\n\n``` php\n[2026-08-23 08:30:00] com.lily.autolike.lane1 実行中(pid=12345) のため見送り (30 -> 45)\n```\n\n(*\"deferred because it's running (pid=12345)\"*)\n\nSo 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.\n\n```\nlaunchctl bootout \"$D/$L\" 2>/dev/null\nif launchctl bootstrap \"$D\" \"$P\" 2>/dev/null; then\n  echo \"[$(date '+%F %T')] $L reloaded $have -> $want\" >>\"$LOG\"; changed=$((changed+1))\nelse\n  echo \"[$(date '+%F %T')] $L RELOAD FAILED\" >>\"$LOG\"\nfi\n```\n\nOnly when the job is confirmed stopped does it run `bootout`\n\n→ `bootstrap`\n\n. `bootout`\n\nunloads it once, and `bootstrap`\n\nre-reads the plist and loads it again, which pulls the new value of `AUTOLIKE_TIMEOUT_SEC`\n\ninto launchd's memory.\n\nA successful log line looks like this:\n\n``` php\n[2026-08-23 08:30:05] com.lily.autolike.lane1 reloaded 30 -> 45\n```\n\nPutting the before and after values side by side makes \"what changed\" obvious at a glance. On failure, `RELOAD FAILED`\n\nis 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.\n\n```\n[ \"$changed\" -gt 0 ] && echo \"[$(date '+%F %T')] done changed=$changed\" >>\"$LOG\"\n```\n\nWhen `changed=0`\n\n(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.\n\n**Making PATH explicit** is handled by a single line at the top of the script.\n\n```\nPATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH\n```\n\nA 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`\n\nand `launchctl`\n\nfails, and you sink into the classic swamp of \"it works on my machine but not when launchd starts it.\" Together with `set -uo pipefail`\n\n, this structure blocks unintended behavior from the outset.\n\n`set -uo pipefail`\n\nkeeps things from breaking\nThe second line of the script is this:\n\n```\nset -uo pipefail\n```\n\nWithout that one line, the script can fall into a state where it \"appears to work but does nothing.\" `-u`\n\nmakes references to undefined variables an error. Suppose you typo'd the variable name used to fetch `$have`\n\nsomewhere. Without `-u`\n\n, the typo'd variable expands to an empty string, and `$have`\n\nin `[ \"$want\" = \"$have\" ]`\n\nbecomes empty. \"want is `45`\n\n, 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`\n\n's output, so no PID is retrieved, `[ -n \"$pid\" ]`\n\nis false — meaning it misjudges the job as \"stopped\" and runs `bootout`\n\n→ `bootstrap`\n\n. 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`\n\nforces early discovery of this class of bug by making the shell stop with an error.\n\n`-o pipefail`\n\ncontrols the exit code of the pipeline as a whole. Without `-o pipefail`\n\n, 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`\n\nis empty, `$?`\n\nis 0 as long as `head -1`\n\nexits normally (exit code 0). Since this script doesn't use `set -e`\n\n, that isn't an immediate problem — but because the presence or absence of `pipefail`\n\ncan change behavior when you modify this script in the future, it's safer to state it explicitly from the start.\n\nWriting plain `-uo`\n\nwithout the `-o`\n\nhas the same effect, but writing `set -uo pipefail`\n\nexplicitly conveys the intent that \"pipefail was configured deliberately.\"\n\n```\nfor P in \"$HOME\"/Library/LaunchAgents/com.lily.autolike.*.plist; do\n  case \"$P\" in *.bak-*|*.disabled*) continue ;; esac\n```\n\nAfter narrowing target files with a glob pattern, there's a second stage that excludes with `case`\n\n. The reason for using `case`\n\nrather than `find`\n\noptions or negated glob syntax is clear. Shell negated globs (extglob forms like `!(*.bak*)`\n\n) are poorly portable, and bash and zsh differ in the options needed to enable them. Scripts launched from launchd run under `/bin/bash`\n\n, so unless you explicitly enable extglob with `set`\n\n, you can't use it. Also, when exclusion patterns grow, `case`\n\nonly needs another `|`\n\n, which keeps it readable.\n\nThe `.bak-`\n\nsuffix 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`\n\ndoesn't match `*.plist`\n\n, but depending on the editor, some create the backup under a different name next to the `.plist`\n\nrather than in `.plist.bak`\n\nform. `.disabled`\n\nis 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`\n\n.\n\n```\nhave=$(launchctl print \"$D/$L\" 2>/dev/null \\\n  | sed -n 's/.*AUTOLIKE_TIMEOUT_SEC => \\([0-9]*\\).*/\\1/p' \\\n  | head -1)\n```\n\nIn the output of `launchctl print gui/501/com.lily.autolike.lane1`\n\n, the part carrying environment variables looks like this (`501`\n\nbeing the actual UID):\n\n``` js\nenvironment = {\n    AUTOLIKE_TIMEOUT_SEC => 30\n    HOME => /Users/...\n}\n```\n\nThe sed expression `s/.*AUTOLIKE_TIMEOUT_SEC => \\([0-9]*\\).*/\\1/p`\n\ntargets the whole line, swallows everything before and after the key name with `.*`\n\n, captures only the numeric part into a capture group with `\\([0-9]*\\)`\n\n, and prints it as `\\1`\n\n. The combination of the `-n`\n\nflag and `p`\n\nmeans \"print only matching lines,\" so nothing is printed if the environment variable doesn't exist.\n\nNarrowing to the first line with `head -1`\n\nprevents multiple matches in case a job name or another setting happens to contain a string resembling `AUTOLIKE_TIMEOUT_SEC =>`\n\n. It can't really happen in practice, but defensively taking a single line eliminates the situation where \"multiple lines come back, `$have`\n\nbecomes multi-line, and every subsequent comparison fails.\"\n\n``` php\npid=$(launchctl list | awk -v l=\"$L\" '$3==l{print $1}')\nif [ -n \"$pid\" ] && [ \"$pid\" != \"-\" ]; then\n```\n\nThe output of `launchctl list`\n\nhas three columns.\n\n```\nPID     Status  Label\n12345   0       com.lily.autolike.lane1\n-       0       com.lily.autolike.lane2\n```\n\nThe first column is the PID: a number when the job is currently running, and `-`\n\nwhen it's stopped (waiting for the next StartInterval). `awk -v l=\"$L\" '$3==l{print $1}'`\n\nprints the first column of the row whose third column matches the label.\n\nTesting with only `[ -n \"$pid\" ]`\n\nis not enough. `-n`\n\ntests \"not an empty string,\" so the string `-`\n\nis non-empty and evaluates to true. In other words, you get a state where \"a stopped job with PID `-`\n\nis misjudged as running and deferred forever.\" The additional `[ \"$pid\" != \"-\" ]`\n\ncheck prevents that misjudgment.\n\n`2>/dev/null`\n\non bootout\n\n```\nlaunchctl bootout \"$D/$L\" 2>/dev/null\nif launchctl bootstrap \"$D\" \"$P\" 2>/dev/null; then\n```\n\nThe `bootout`\n\nline has neither `|| exit 1`\n\nnor `|| continue`\n\n. The design throws away errors with `2>/dev/null`\n\n, ignores the exit code, and moves on to `bootstrap`\n\n.\n\nThere's a reason for this. `bootout`\n\nmay 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`\n\nreturns 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`\n\nnever runs and the whole re-sync fails. Discarding errors with `2>/dev/null`\n\nand unconditionally proceeding to `bootstrap`\n\nsecures idempotence.\n\n`bootstrap`\n\n, on the other hand, has its success checked with `if`\n\n. A `bootstrap`\n\nfailure — a syntax error in the plist file, insufficient permissions — means the abnormal state \"the re-sync failed,\" so the design leaves `RELOAD FAILED`\n\nin the log and retries on the next run.\n\n```\nchanged=0\n# ... ループ内で changed=$((changed+1)) ...\n[ \"$changed\" -gt 0 ] && echo \"[$(date '+%F %T')] done changed=$changed\" >>\"$LOG\"\n```\n\nWhen `changed`\n\nis 0, no summary line is printed. That's intentional. This script is registered as a periodic launchd job. All lanes matching on `want == have`\n\nis 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.\n\n\"Deferred,\" meanwhile, is logged every time the loop comes around.\n\n``` php\necho \"[$(date '+%F %T')] $L 実行中(pid=$pid) のため見送り ($have -> $want)\" >>\"$LOG\"\n```\n\nThat'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`\n\nline eventually appears, you can confirm it completed normally; if `RELOAD FAILED`\n\narrives, 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.\"\n\nWhen I first wrote the script, the PID check looked like this:\n\n``` php\npid=$(launchctl list | awk -v l=\"$L\" '$3==l{print $1}')\nif [ -n \"$pid\" ]; then\n  echo \"実行中のため見送り\"\n  continue\nfi\n```\n\nThe 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.\"\n\n``` php\n[2026-08-10 01:00:00] com.lily.autolike.lane2 実行中(pid=-) のため見送り (30 -> 45)\n[2026-08-10 02:00:00] com.lily.autolike.lane2 実行中(pid=-) のため見送り (30 -> 45)\n[2026-08-10 03:00:00] com.lily.autolike.lane2 実行中(pid=-) のため見送り (30 -> 45)\n```\n\nSeeing the output `pid=-`\n\nis what tipped me off. For a stopped job, `launchctl list`\n\ndoesn't leave the PID column empty — it puts in the single character `-`\n\n. Since `-`\n\nisn't an empty string, `[ -n \"$pid\" ]`\n\nis always true, and no matter how long you wait it keeps being judged \"running.\"\n\nThe fix is a single added line.\n\n```\nif [ -n \"$pid\" ] && [ \"$pid\" != \"-\" ]; then\n```\n\nNow it judges \"running\" only when a PID exists and it isn't the `-`\n\nthat indicates stopped. This is a pit you're guaranteed to fall into if you don't know launchd's conventional notation, and `man launchctl`\n\nonly mentions it in passing. I only noticed after looking at actual `launchctl list`\n\noutput with my own eyes.\n\nWhen 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`\n\ncounter not moving, not even deferral lines were coming out.\n\nBecause of `set -uo pipefail`\n\n, it had to be stopping on an error. Adding `exec >> /tmp/debug.log 2>&1`\n\nat the top of the script to capture debug output for the experiment produced this error:\n\n```\n/bin/bash: /usr/libexec/PlistBuddy: No such file or directory\n```\n\n`PlistBuddy`\n\nuses the absolute path `/usr/libexec/PlistBuddy`\n\n, yet it wasn't found — the cause was that despite line 14 specifying the absolute path with `PB=/usr/libexec/PlistBuddy`\n\n, the expansion of `$PB`\n\nwas coming out empty for some reason. The `-u`\n\noption turned the empty-variable reference into an error, and the `for`\n\nloop terminated in a way closer to `exit`\n\nthan `continue`\n\n.\n\nDigging in, I found leftover traces before `PB=/usr/libexec/PlistBuddy`\n\nof an attempt to use `PlistBuddy`\n\nthrough a different variable (commented out, but actually causing a separate problem because of `-u`\n\n). The variable reference had been broken during cleanup — that's the precise cause.\n\nBut 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`\n\nadded in `.zshrc`\n\nor `.zprofile`\n\n). Without explicitly setting `PATH=/usr/bin:/bin:/usr/sbin:/sbin`\n\n, the commands you use inside the script end up in the \"works locally, doesn't work under launchd\" state.\n\nSince `PlistBuddy`\n\nlives in `/usr/libexec/`\n\n, the absolute-path form `PB=/usr/libexec/PlistBuddy`\n\nis fine, but `launchctl`\n\n, `sed`\n\n, `awk`\n\n, and `date`\n\nare all resolved via PATH. `/usr/bin:/bin:/usr/sbin:/sbin`\n\nis 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.\"\n\n```\nPATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH\n```\n\n`launchctl print`\n\noutput came back empty and every lane was skipped\nOne morning I hit a situation where `have`\n\nwas empty for every lane, so the script skipped everything. Nothing was in the log, so the state was \"exiting normally but skipping.\"\n\nRunning `launchctl print gui/501/com.lily.autolike.lane1`\n\ndirectly in the terminal returned an error.\n\n```\nCould not find service \"com.lily.autolike.lane1\" in domain for port\n```\n\nFor a job not loaded into launchd, `launchctl print`\n\nreturns an error rather than an empty string. Since `2>/dev/null`\n\ndiscards the error, `have`\n\nbecomes empty and `[ -n \"$have\" ] || continue`\n\nskips it.\n\nTracing the cause back, I found that during some other configuration work the night before I had mistakenly run `launchctl bootout`\n\nagainst several lanes and reached morning having forgotten the `bootstrap`\n\n. When the jobs themselves don't exist, `autolike-plist-reconcile.sh`\n\ncan do nothing. A feature for \"raising an alert when it detects an unloaded job\" is outside this script's scope.\n\nThat'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.\n\nDuring a period when I was editing plists directly in a text editor, the editor was creating automatic backups inside `~/Library/LaunchAgents/`\n\n, in a form like `com.lily.autolike.lane1.plist~`\n\n(`~`\n\nbeing the backup suffix some editors use). That file doesn't match the glob `*.plist`\n\n, so it was harmless — but a file left in the form `com.lily.autolike.lane1.plist.bak-20260810`\n\ndid match the glob.\n\nBecause the backup file also starts with `com.lily.autolike.`\n\nand ends with `.plist`\n\n, the script treats it as a processing target. Read the backup's `want`\n\nwith `PlistBuddy`\n\n, read the job's `have`\n\nwith `launchctl print`\n\n, 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`\n\ndiffered from the running job's `have`\n\nand an unnecessary `bootout`\n\n→ `bootstrap`\n\nfired.\n\nThe exclusion pattern `case \"$P\" in *.bak-*|*.disabled*) continue ;; esac`\n\nwas added from that experience. It's tuned to the editor's automatic backup naming convention with the `.bak-`\n\npattern (with the hyphen, because plain `.bak`\n\nrisks 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.\n\nHere'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 `-`\n\n, unset PATH, empty `launchctl print`\n\n, backup files sneaking in), I've organized the patterns that keep catching you during the scaling phase.\n\n`/usr/bin:/bin:/usr/sbin:/sbin`\n\n.`command not found`\n\nwhen launched by launchd. Nothing starts until you write `PATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH`\n\non the first line.`/usr/libexec/PlistBuddy`\n\n(`PB=/usr/libexec/PlistBuddy`\n\n). Trying to add it to PATH is pointless because `/usr/libexec`\n\nis outside the default PATH.`exec`\n\npermission.`ProgramArguments`\n\n, but if you forget `chmod +x`\n\nit dies instantly with `Permission denied`\n\n. Nothing is left in the log either, so diagnosis is slow.`Program`\n\nand `ProgramArguments[0]`\n\n.`Program`\n\nwithout knowing that `ProgramArguments[0]`\n\nis then treated as argv[0], and write both, your own script — not `/bin/bash`\n\n— gets passed directly and is treated as a syntax error.`awk -v l=\"$L\"`\n\nused to filter `launchctl list`\n\ntakes its value from `$L=$(basename \"$P\" .plist)`\n\n, 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`\n\nfor verification, it's processed just like production. Unless you follow the convention of excluding it with a `.disabled`\n\nsuffix, a test configuration can overwrite the production environment.`launchctl list`\n\nprints the Label itself, so uppercase notation that differs from the filename makes the `awk`\n\nmatch fail. Standardize the naming convention so everything after `com.lily.autolike.`\n\nis all lowercase, dot-separated.`environment = {`\n\nchanged and the sed pattern stopped matching. Because `sed -n 's/.*AUTOLIKE_TIMEOUT_SEC => \\([0-9]*\\).*/\\1/p'`\n\nis designed so the `.*`\n\nswallows 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`\n\n. Starting label names with `com.lily.`\n\nmakes real damage unlikely, but the premise is using exact matching (`$3==l`\n\n) so `awk`\n\ndoesn't react to unexpected labels.`launchctl print`\n\nwrong.`gui/$(id -u)/com.lily.autolike.lane1`\n\nis correct, but writing `user/$(id -u)/…`\n\npoints at a different domain. Making `D=\"gui/$(id -u)\"`\n\na variable managed in one place erases the risk of a typo propagating across the whole script.`AUTOLIKE_TIMEOUT_SEC`\n\nin multiple places in the script.`\\([0-9]*\\)`\n\nfails to match when a non-integer value is used.`AUTOLIKE_TIMEOUT_SEC`\n\nis written with a unit, like `45s`\n\n, `[0-9]*`\n\ncomes 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`\n\nkey doesn't exist in the plist.`|| continue`\n\nin `$PB -c \"Print :EnvironmentVariables:AUTOLIKE_TIMEOUT_SEC\" \"$P\" 2>/dev/null || continue`\n\nhandles this case, but if you remove `2>/dev/null`\n\nwhile 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\"`\n\n(`~/.claude/logs/`\n\n) hasn't been created, `echo … >>\"$LOG\"`\n\nerrors and the script dies from the write error rather than from `set -uo pipefail`\n\n's `-u`\n\n. Run `mkdir -p ~/.claude/logs`\n\nbefore registering the plist.`changed=0`\n\nfor \"normal.\"`want == have`\n\n, but the case where have is empty (jobs not loaded) and everything is skipped also produces `changed=0`\n\nwith no log output. Periodically confirm through a separate channel — `launchctl list | grep com.lily.autolike`\n\n— that all lanes are loaded.`launchctl list`\n\nwhile 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.`-`\n\nonly 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.\n\n**① Pin PATH on the first line**\n\n```\nPATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH\n```\n\nThe 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`\n\nor `.zprofile`\n\n. Pinning PATH on the leading line structurally eliminates the \"works locally, doesn't work under launchd\" problem.\n\n**② Always write set -uo pipefail**\n\n```\nset -uo pipefail\n```\n\n`-u`\n\nturns undefined variables into immediate errors, and `pipefail`\n\npropagates 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.\n\n**③ Put PlistBuddy in a variable as an absolute path**\n\n```\nPB=/usr/libexec/PlistBuddy\n```\n\nSince 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.\n\n**④ Put the launchd domain specification in a variable**\n\n```\nD=\"gui/$(id -u)\"\n```\n\nWriting the `gui/UID`\n\nform inline every time raises typo risk. Consolidating it into one variable means `launchctl print \"$D/$L\"`\n\n, `launchctl bootout \"$D/$L\"`\n\n, and `launchctl bootstrap \"$D\" \"$P\"`\n\nall follow from a single change.\n\n**⑤ Manage exclusion patterns with case**\n\n```\ncase \"$P\" in *.bak-*|*.disabled*) continue ;; esac\n```\n\nRather than relying on extglob or negated globs, excluding with `case`\n\nis the most portable design. When exclusion patterns grow, you just append with `|`\n\n. Matching the editor's backup naming convention with `.bak-`\n\n(with the hyphen) prevents mistaken matches on plain `.bak`\n\n.\n\n**⑥ Make \"skip on failure\" explicit when fetching want and have**\n\n```\nwant=$($PB -c \"Print :EnvironmentVariables:AUTOLIKE_TIMEOUT_SEC\" \"$P\" 2>/dev/null) || continue\nhave=$(launchctl print \"$D/$L\" 2>/dev/null | sed -n '...' | head -1)\n[ -n \"$have\" ] || continue\n```\n\nA failed want fetch (a plist without the key) skips immediately via `|| continue`\n\n. 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`\n\npipe can exit 0 as a whole.\n\n**⑦ Explicitly judge a PID of - as \"stopped\"**\n\n```\nif [ -n \"$pid\" ] && [ \"$pid\" != \"-\" ]; then\n```\n\n`launchctl list`\n\nputs the string `-`\n\n, not a numeric PID, for stopped jobs. The `-n`\n\ntest alone misjudges `-`\n\nas \"has a PID\" and defers forever. Always add `[ \"$pid\" != \"-\" ]`\n\n. Since it isn't spelled out in `man launchctl`\n\n, this is a pit you can't notice until you look at real output with your own eyes.\n\n**⑧ Make bootout idempotent; check success only for bootstrap**\n\n```\nlaunchctl bootout \"$D/$L\" 2>/dev/null\nif launchctl bootstrap \"$D\" \"$P\" 2>/dev/null; then\n  …; changed=$((changed+1))\nelse\n  echo \"… RELOAD FAILED\" >>\"$LOG\"\nfi\n```\n\n`bootout`\n\ngets called even when the job is already unloaded. Stopping on the error means the following `bootstrap`\n\nnever runs and the whole re-sync fails. Discard errors with `2>/dev/null`\n\nand proceed unconditionally to `bootstrap`\n\nto secure idempotence. Log only `bootstrap`\n\n's success or failure and let the next run retry.\n\n**⑨ Log only changes and waits**\n\n```\n# 見送り → 毎回記録する（デバッグのため）\necho \"[$(date '+%F %T')] $L 実行中(pid=$pid) のため見送り ($have -> $want)\" >>\"$LOG\"\n\n# 変更なし → 記録しない\n# 変更あり → 記録する\n[ \"$changed\" -gt 0 ] && echo \"[$(date '+%F %T')] done changed=$changed\" >>\"$LOG\"\n```\n\n(*The comments read: \"deferred → record every time (for debugging)\", \"no change → don't record\", \"changed → record\".*)\n\nMake fully normal runs silent. Keep deferrals, because they're needed to debug \"I changed it but it isn't taking effect.\" `reloaded`\n\nand `RELOAD FAILED`\n\nare state changes, so always keep them. These three tiers let you judge \"did anything happen\" in one line the moment you open the log.\n\n**⑩ Narrow the script's responsibility to one thing**\n\nAll 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.\n\n**⑪ Manage the monitored key name in one place**\n\nThe key name `AUTOLIKE_TIMEOUT_SEC`\n\nappears 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.\n\n```\nKEY=AUTOLIKE_TIMEOUT_SEC\nwant=$($PB -c \"Print :EnvironmentVariables:$KEY\" \"$P\" 2>/dev/null) || continue\nhave=$(launchctl print \"$D/$L\" 2>/dev/null | sed -n \"s/.*$KEY => \\\\([0-9]*\\\\).*/\\\\1/p\" | head -1)\n```\n\nThe 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.\n\n**⑫ Derive the periodic interval from the reflection delay you can tolerate**\n\nThe `StartInterval`\n\nyou 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`\n\n. 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.\"\n\n**⑬ Don't point the log file at the same place as launchd's StandardOutPath**\n\nlaunchd's `StandardOutPath`\n\nand `StandardErrorPath`\n\nare stdout/stderr redirection targets specified in the plist. Manage the reconcile script's own application log (the `~/.claude/logs/autolike-plist-reconcile.log`\n\npointed at by the `LOG`\n\nvariable) 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.\n\n**⑭ Test-run with bash -x**\n\nWhen verifying behavior before registering with launchd, run it with `bash -x`\n\nso commands are traced.\n\n```\nbash -x ~/.claude/scripts/autolike-plist-reconcile.sh\n```\n\nBecause each line's actual expanded values are printed with a `+`\n\nprefix, you can visually confirm whether variables expand as expected and whether the output of `launchctl print`\n\nis being passed properly to `sed`\n\n. Verifying behavior locally before registering with the plist heads off silent bugs (the state where nothing is logged and nothing happens).\n\n**⑮ End with an explicit exit 0**\n\n```\nexit 0\n```\n\nPutting `exit 0`\n\nat the end of the script guarantees a normal exit code back to launchd. Combined with settings like `KeepAlive`\n\n, 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 ] && …`\n\n, the whole `&&`\n\nreturns false (exit code 1) when the condition is false, so the explicit `exit 0`\n\nis necessary.\n\nIn one sentence, what `autolike-plist-reconcile.sh`\n\ndoes 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.\"\n\nConcretely, four steps:\n\n`want`\n\n) with `PlistBuddy`\n\n`have`\n\n) with `launchctl print`\n\n`launchctl list`\n\n`-`\n\n(stopped), re-sync with `bootout`\n\n→ `bootstrap`\n\n; 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.\n\nThe 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.\"\n\nA ¥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.\n\nOne 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?\n\nI'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.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until", "canonical_source": "https://dev.to/bokuwalily/editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until-31-lines-of-bash-10d9", "published_at": "2026-08-29 11:00:07+00:00", "updated_at": "2026-08-29 11:18:58.187268+00:00", "lang": "en", "topics": ["developer-tools", "mlops"], "entities": ["Lily", "launchd", "macOS", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until", "markdown": "https://wpnews.pro/news/editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until.md", "text": "https://wpnews.pro/news/editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until.txt", "jsonld": "https://wpnews.pro/news/editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until.jsonld"}}