cd /news/developer-tools/a-4-minute-freeze-a-dead-path-and-3-โ€ฆ ยท home โ€บ topics โ€บ developer-tools โ€บ article
[ARTICLE ยท art-104041] src=dev.to โ†— pub= topic=developer-tools verified=true sentiment=ยท neutral

A 4-Minute Freeze, a Dead PATH, and 3 More: 5 launchd Pitfalls From Mapping 26 Automation Jobs

A developer who rebuilt an autonomous Claude Code environment after a layoff has mapped 26 launchd automation jobs on his Mac to track which are alive and which are dead. He created env-map.sh, which generates a daily environment map in Obsidian, showing the PC, Claude, and project layers, and leverages git to record changes over time. The tool, which runs twice daily via launchd, has helped him manage his automation stack that now generates ยฅ1.2M/month in revenue.

read26 min views6 publishedAug 20, 2026

Automation doesn't fail loudly. It accumulates until nobody โ€” including the person who wrote it โ€” can say which jobs are still alive. I started with part-time gigs paying ยฅ100k/month, grew that to ยฅ600k, got laid off down to zero, and spent six months rebuilding an autonomous Claude Code environment that now brings in ยฅ1.2M/month in revenue. This post is about the problem that showed up along the way โ€” "too much automation, no idea what's running and what's dead" โ€” and how I solved it.

Automation grows slowly. One launchd job at first, then two, and before you know it, 26.

My Mac currently runs 26 com.shun.*.plist

jobs. vault-ingest fires at 4:55 every morning, daily brief runs at 7:30, github-scout cycles on a schedule, and on top of that env-map โ€” the subject of this post โ€” fires twice, at 4:50 and 8:10. That's already close to ten. The rest are Metrics aggregation, an auto-committer, and scheduled social media processing.

The problem isn't that the count goes up. It's that you lose track of which ones are alive and which are dead.

launchd silently restarts processes when they die. Error logs get dumped into ~/.claude/logs/

, but multiple plists write to the same directory, so things scroll away. Sometimes it takes two days before I notice "huh, the Metrics numbers haven't updated." Detection lags because there's no map.

There's an important distinction here. "Looking at work logs" and "reading the environment" are different things.

A work log is a record of what you did yesterday. An environment map is a snapshot of what is running, what exists, and what is dead at this exact moment. What I wanted was the latter.

Concretely: when I open Obsidian, I wanted this morning's Mac state to be readable on one page. That my Claude environment has 47 plugins, 358 agents, and 112 auto-skills. That there are 11 projects, two of which don't exist on disk (not found). That lead-finder has 75 uncommitted changes piled up. I didn't want to type commands every morning to check this โ€” I wanted it to just be there when I opened Obsidian.

There's a second mechanism at play. My vault gets a git commit from vault-ingest every morning. environment-map.md is overwritten daily too, so if lead-finder was "clean" yesterday and shows "โœŽ75" today, the git diff shows it.

I didn't design this on purpose. It fell out of two existing facts โ€” "the Obsidian vault was already under git" and "environment-map.md gets written into that vault" โ€” combining into a structure that records change automatically. It turned out to be more powerful than I expected: I can go back and trace what the environment looked like on any given day.

env-map.sh splits the environment into PC layer, Claude layer, and project layer, generates Mermaid diagrams and tables, and writes them to wiki/meta/environment-map.md

in the Obsidian vault.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    launchd ใ‚นใ‚ฑใ‚ธใƒฅใƒผใƒฉ                   โ”‚
โ”‚  com.shun.env-map.plist                                  โ”‚
โ”‚  โ†’ 04:50 / 08:10 ใฎ2ๅ›ž็™บ็ซ                              โ”‚
โ”‚  โ†’ LowPriorityIO=true / Nice=10๏ผˆใƒใƒƒใ‚ฏใ‚ฐใƒฉใ‚ฆใƒณใƒ‰ๅ„ชๅ…ˆๅบฆ๏ผ‰ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                    โ”‚ /bin/bash ~/.claude/scripts/env-map.sh
                    โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                   env-map.sh                             โ”‚
โ”‚                                                          โ”‚
โ”‚  โ‘  PC ็’ฐๅขƒๅŽ้›†                                           โ”‚
โ”‚     sw_vers / sysctl / df / CLIๅœจๅฆใƒใ‚งใƒƒใ‚ฏ              โ”‚
โ”‚                                                          โ”‚
โ”‚  โ‘ก Claude ็’ฐๅขƒๅŽ้›†                                       โ”‚
โ”‚     ~/.claude/settings.json ใ‹ใ‚‰ pluginๆ•ฐใƒปhookๆ•ฐ        โ”‚
โ”‚     find ใงskill/agentๆ•ฐใ‚ซใ‚ฆใƒณใƒˆ                         โ”‚
โ”‚     ls ~/Library/LaunchAgents/com.shun.*.plist ใงไปถๆ•ฐ   โ”‚
โ”‚                                                          โ”‚
โ”‚  โ‘ข ใƒ—ใƒญใ‚ธใ‚งใ‚ฏใƒˆ็’ฐๅขƒๅŽ้›†                                   โ”‚
โ”‚     11 repos ร— git branch/log/status                    โ”‚
โ”‚                                                          โ”‚
โ”‚  โ‘ฃ Mermaidๅ›ณ + ่กจใ‚’ $TMP ใซ็”Ÿๆˆ                          โ”‚
โ”‚     โ†’ mv $TMP $OUT๏ผˆใ‚ขใƒˆใƒŸใƒƒใ‚ฏๆ›ธใ่พผใฟ๏ผ‰                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                    โ”‚
                    โ–ผ
    ~/Documents/claude-obsidian/wiki/meta/environment-map.md
                    โ”‚
                    โ–ผ
    vault-ingest๏ผˆๅˆฅใ‚ธใƒงใƒ–๏ผ‰ใŒgitใ‚ณใƒŸใƒƒใƒˆ
    โ†’ ๅทฎๅˆ† = ๅ‰ๆ—ฅใ‹ใ‚‰ใฎๅค‰ๅŒ–่จ˜้Œฒ

set -e

set -uo pipefail

I use set -uo pipefail

instead of set -e

. The reason is spelled out in the comment: "run generation to completion no matter what happens (individual collection failures degrade to ?

)."

For a script that runs via launchd in the early morning, MCP connections can be flaky, a project's git repository can be broken, and network-dependent commands can time out. If one failing command kills the entire script, you don't get a map that day. Replace the failed piece with ?

and emit the rest normally โ€” that's the degrade

design.

Here's how the MCP connection count is actually written:

MCP_OK="?"
if have claude; then
  _mcp="$(timeout 12 claude mcp list 2>/dev/null)"
  [ -n "$_mcp" ] && MCP_OK="$(printf '%s' "$_mcp" | grep -c 'Connected')"
fi

Initialize with ?

first, attempt with a 12-second timeout if the claude

command exists, and overwrite with the connection count if it succeeds. If it can't connect, ?

goes into the Mermaid diagram as-is. In today's actual output it reads MCP connected | ?

, which tells you this path was taken.

launchd's PATH at startup is extremely bare. It's a different thing from the PATH of a terminal launched via the GUI โ€” no nvm-managed node, no Homebrew tools.

The plist's EnvironmentVariables:

<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>

But there's additional work on the script side too:

NVM_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:${NVM_BIN:+$NVM_BIN:}/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

The plist PATH hardcodes the node version as v24.13.0

. Bump the version and you have to rewrite the plist. To avoid that, the script dynamically resolves the newest bin directory with ls -d ... | sort -V | tail -1

and overwrites PATH. The plist is a constant-ish initial value, the script is dynamic correction โ€” two layers of insurance.

Mermaid diagrams break entirely when special characters appear in labels. Project names can contain parentheses, branch names can contain slashes, so I define this sanitize function as a one-liner:

san() { printf '%s' "$1" | tr '"[]()#|<>' '        ' | tr '\n' ' ' | sed 's/  */ /g; s/ *$//'; }

It replaces the nine characters "[]()#|<>

with spaces, flattens newlines into spaces, and collapses runs of spaces into one. The project name ใ€ŒๅฐฑๆดปใƒŠใƒ“(SEO)ใ€ becomes ใ€ŒๅฐฑๆดปใƒŠใƒ“ SEO ใ€ in Mermaid. Looking at the actual output, it renders as p9["ๅฐฑๆดปใƒŠใƒ“ SEO<br/>main ยท 2026-07-02 ยท โœŽ1"]

โ€” the parentheses are gone.

Metadata for the 11 projects is gathered by the proj_meta()

function:

proj_meta() {
  local path="$1"
  PROJ_EXISTS=0; PROJ_BRANCH="-"; PROJ_LAST="-"; PROJ_DIRTY=0
  [ -d "$path" ] || return
  PROJ_EXISTS=1
  if git -C "$path" rev-parse --git-dir >/dev/null 2>&1; then
    PROJ_BRANCH="$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '-')"
    PROJ_LAST="$(git -C "$path" log -1 --format=%cd --date=format:%Y-%m-%d 2>/dev/null || echo '-')"
    PROJ_DIRTY="$(git -C "$path" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
  fi
}

Directory doesn't exist โ†’ PROJ_EXISTS=0

, drawn in Mermaid as a red node labeled "(ๆœชๆคœๅ‡บ)". Exists but isn't a git repo โ†’ branch/last/dirty stay as hyphens. If it is a git repo, grab branch, last commit date, and uncommitted count.

In today's actual output:

This auto-updates every morning at 8:10.

Generation writes everything to $TMP="$(mktemp)"

first, then overwrites with mv "$TMP" "$OUT"

:

} > "$TMP"

mkdir -p "$(dirname "$OUT")"
mv "$TMP" "$OUT"
echo "[$(date '+%F %T')] environment-map.md generated ($(wc -l < "$OUT" | tr -d ' ') lines)"

Even if a write lands while Obsidian is reading the file, mv

is atomic, so no intermediate state is visible. mkdir -p

guarantees the output directory, and the last line logs the line count and timestamp. That log goes to ~/.claude/logs/env-map.launchd.log

, so you can confirm from the log whether cron actually ran.

Here are the numbers from the environment map generated this morning (2026-08-02 08:10:05), quoted directly:

PC environment

Claude environment

| Item | Value |

|---|---|

| Plugin skills | 3367 |

| Auto skills (self-generated) | 112 |

| Agents | 358 |

| Enabled plugins | 47 |

| Hook events | 7 |

| launchd jobs | 26 |

Project environment (11 repos)

This is on the first page every morning when I open Obsidian.

The script's entire output is wrapped in a single { ... } > "$TMP"

block. By applying Bash's output redirection to a block, everything flows into the same file no matter how many times you switch between cat

, echo

, loops, and function calls.

{
cat <<HEADER
---
type: meta
title: "็’ฐๅขƒใƒžใƒƒใƒ—๏ผˆ่‡ชๅ‹•็”Ÿๆˆ๏ผ‰"
updated: $ISO
...
HEADER

i=0
for c in $CLI_LIST; do
  echo "  CLI --> cli$i[\"$c\"]"
  i=$((i+1))
done

cat <<'PCT'

| ้ …็›ฎ | ๅ€ค | |---|

PCT

} > "$TMP"

The key point is using two kinds of heredoc. `<<HEADER` (unquoted) expands shell variables, so you can write `$ISO` or `$OS_VER` directly. `<<'PCT'` (single-quoted) suppresses expansion. You need the latter where you want to emit Markdown backticks or pipe characters literally โ€” for example, code block opening/closing lines. The first time I unified everything on `<<HEADER`, the backticks in the Mermaid diagram got interpreted as bash variables and produced a syntax error.

### What the plist's three priority settings mean

The plist has three lines that essentially say "run quietly."

xml

LowPriorityIO

Nice

10

ProcessType

Background

**`LowPriorityIO`** lowers disk I/O priority. 4:50 is right before vault-ingest (4:55), and Obsidian sync may be running. This setting prevents fighting over I/O bandwidth. **`Nice=10`** lowers CPU priority โ€” below the `Nice=0` of normal processes, background-only. **`ProcessType=Background`** sets the macOS QoS class to Background so it doesn't wake a Mac in a power-saving state. Setting all three together produces behavior that "doesn't get in anything's way."

Setting `RunAtLoad: false` is also deliberate. If it runs the instant you `launchctl load`, you can't verify whether the plist's PATH settings took effect or whether the script's dependencies are present. The point is to follow a procedure: run `launchctl kickstart gui/$(id -u)/com.shun.env-map` manually once to try it, then hand it over to the schedule.

### Why one log file is enough

The plist points standard output and standard error at the same file.

xml

StandardErrorPath

~/.claude/logs/env-map.launchd.log

StandardOutPath

~/.claude/logs/env-map.launchd.log

At the end of the script is this line:

bash

echo "[$(date '+%F %T')] environment-map.md generated ($(wc -l < "$OUT" | tr -d ' ') lines)"

Today's log records `[2026-08-02 08:10:05] environment-map.md generated (148 lines)`. That single log line tells you "when it ran" and "how many lines it generated." Errors land in the same file, so `tail ~/.claude/logs/env-map.launchd.log` is all you need to see the state. The `tr -d ' '` after `wc -l` is there because macOS's `wc` prefixes output with spaces (e.g. `  148`). Without stripping that, the Mermaid node label ends up reading `  148 jobs`, which looks off.

### Why join_nodes() became necessary

To apply the same class to multiple nodes in Mermaid, you need comma-separated form: `class p1,p2,p5 dirty;`. Writing them individually as `class p1 dirty; class p2 dirty;` is grammatically valid too, but with many entries the line count grows and the generated diagram source becomes hard to read. More problematic was that with some Mermaid parsers, individual multiple `class` declarations behaved unstably. I consolidated on `join_nodes()`, which reformats a space-separated string into comma-separated form.

bash

join_nodes() { printf '%s' "$1" | sed 's/^ *//; s/ *$//; s/ */ /g; s/ /,/g'; }

[ -n "$DIRTY_NODES" ] && echo " class $(join_nodes "$DIRTY_NODES") dirty;"

[ -n "$GONE_NODES" ] && echo " class $(join_nodes "$GONE_NODES") gone;"

It strips leading/trailing whitespace, normalizes runs of spaces to one, then converts to commas. `DIRTY_NODES` accumulates via `"$DIRTY_NODES $nid"`, which produces a leading space each time, so the stripping is required. In today's actual output, seven nodes line up cleanly as `class p1,p3,p4,p6,p7,p8,p9 dirty;`.

### Why counting auto skills needs grep

The auto skill count line is a little unusual.

bash

AUTO_SKILLS="$(ls "$HOME/.claude/skills/auto/" 2>/dev/null | grep -vc README)"

It lists the directory with `ls` and excludes README with `grep -vc README`. Under `~/.claude/skills/auto/`, skill-name subdirectories coexist with a management `README.md`. A naive `ls | wc -l` counts `README.md` as one entry, making the number one higher than reality. Today's value is 112, and that's the accurate count excluding the README. Incidentally, using `-vc` (count mode) rather than `grep -v README` collapses the grep-plus-wc pipe into a single command.

---

## Where I got stuck

From here on, the things that actually tripped me up. All of them are stories about how long it took to get from "something isn't working" to "here's why it isn't working."

### Stuck #1: the script froze for over 4 minutes on an MCP connection

**Symptom**: I opened Obsidian in the morning and the map still showed yesterday. Checking the log, `env-map.launchd.log` was empty. The script had output nothing.

**Cause**: Initially I fetched the MCP connection count with a bare `claude mcp list`. When MCP connections are unstable โ€” specifically when an MCP server is sitting in a timeout wait โ€” this command blocks for over 4 minutes without returning. The script running at 4:50 in the morning stalled waiting on MCP and ended with zero output.

**Fix**: I added a forced cutoff with `timeout 12`.

bash

MCP_OK="?"

if have claude; then

_mcp="$(timeout 12 claude mcp list 2>/dev/null)"

[ -n "$_mcp" ] && MCP_OK="$(printf '%s' "$_mcp" | grep -c 'Connected')"

fi

If there's no response within 12 seconds, `MCP_OK` stays `?` and goes into the Mermaid diagram. Today's output also reads `MCP connected | ?`, which tells me the connection was unstable that morning. I treat "?" as meaning "indeterminate at this moment," not "fetch failed." The map gets generated even if MCP is dead โ€” that's the priority.

### Stuck #2: parentheses in a project name wiped out the Mermaid diagram

**Symptom**: I opened the map page in Obsidian and every diagram section showed "Parse error: Expecting 'CloseBrace'". The text tables displayed normally, so it was immediately clear that only the Mermaid blocks were broken.

**Cause**: It started right after I added `"ๅฐฑๆดปใƒŠใƒ“(SEO)|..."` to the PROJECTS list. In Mermaid's node label syntax, `()` has special meaning (notation for stadium-shaped nodes). Embedding `ๅฐฑๆดปใƒŠใƒ“(SEO)` directly as `p9["ๅฐฑๆดปใƒŠใƒ“(SEO)<br/>..."]` breaks the parser, which tries to interpret `(SEO)` as a nested shape specification.

**Fix**: I added processing in the `san()` function that converts nine characters, including `()`, to spaces.

bash

san() { printf '%s' "$1" | tr '"[#|<>' ' ' | tr '\n' ' ' | sed 's/ */ /g; s/ *$//'; }]

The nine characters in `tr`'s target string `'"[]()#|<>'` are the set that either actually broke Mermaid or that I judged likely to. `#` collides with Mermaid comments, `<>` with HTML tags, and `|` with edge label syntax. Today's output reads `p9["ๅฐฑๆดปใƒŠใƒ“ SEO<br/>main ยท 2026-07-02 ยท โœŽ1"]` โ€” the parentheses are gone and the label is safe.

### Stuck #3: upgrading node killed PATH

**Symptom**: `node` disappeared from the map's "key CLIs" field, and the Claude environment's `Agents` count went to `0`. Every node-dependent script was wiped out.

**Cause**: The PATH written into the plist's EnvironmentVariables hardcodes the `v24.13.0` path.

xml

.../.nvm/versions/node/v24.13.0/bin:...

After bumping to `v25.x.x` with nvm, that path pointed at a nonexistent directory. launchd only references the PATH at startup, so the `node` command was no longer found.

**Fix**: I added processing on the script side that dynamically resolves nvm's latest binary and overwrites PATH.

bash

NVM_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"

export PATH="$HOME/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:${NVM_BIN:+$NVM_BIN:}/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

`sort -V` sorts version strings; with plain `sort`, `v10` comes before `v9`. `tail -1` grabs the newest bin directory. `${NVM_BIN:+$NVM_BIN:}` is Bash parameter expansion that keeps a lone `:` from being left behind when `NVM_BIN` is empty. I keep the plist's PATH as "an initial value for the worst case where launchd can't find any of its own tools," but since the script overwrites it dynamically, it works even when the plist's version number is stale.

### Stuck #4: `set -e` cut the map off partway

**Symptom**: I opened the map in Obsidian and the project environment table ended midway. There should have been 11 projects, but it cut off on the fourth line, and after that the Mermaid closing tag wasn't closed either.

**Cause**: Initially I used `set -eo pipefail`. One project's git repository was corrupted and `git rev-parse --git-dir` returned exit 1. Under `set -e`, the script terminates immediately at that point. Since it terminates in the middle of the output block `{ ... } > "$TMP"`, the write to `$TMP` is interrupted and `mv "$TMP" "$OUT"` is never reached. The result is that the previous `$OUT` (yesterday's map) stays in place โ€” actually, it's worse than that: `$TMP` was left in a half-finished state, and the same problem recurred on the next run.

**Fix**:

bash

set -uo pipefail

I dropped `-e`. Undefined variable references (`-u`) and pipe failures (`-o pipefail`) are still detected, while individual commands' exit 1 is handled explicitly with `|| echo '-'` or `|| echo '?'`. Every command inside `proj_meta()` is designed either to have `|| echo '-'` attached or to initialize a global variable with `-` before overwriting it. Even with a broken git repository, only that node is drawn as `branch=-, last=-` and the rest is generated normally.

### Stuck #5: racing with vault-ingest meant committing a stale map

**Symptom**: Looking at the git diff, today's environment-map.md commit content was "exactly the same as yesterday." The uncommitted count for `lead-finder`, which should have changed, wasn't updated.

**Cause**: At first I set env-map's fire time to 4:55. vault-ingest is also 4:55, and sometimes it starts first. If environment-map.md generation isn't complete by the time vault-ingest runs its git commit, the previous day's content gets committed. Since the job startup order shifts slightly on each warm boot of the Mac, which one ran first was nondeterministic.

**Fix**: I moved env-map up to 4:50 (five minutes before vault-ingest). That's why the plist's `StartCalendarInterval` has two entries.

xml

StartCalendarInterval

Hour4

Minute50

Hour8

Minute10

Generate at 4:50, vault-ingest commits at 4:55, then re-collect the post-wake-up state at 8:10 โ€” that three-point schedule is the current design. Five minutes is ample buffer given `env-map.sh`'s maximum runtime (normally under 30 seconds including the 12-second MCP timeout). In fact, this morning's log records `[2026-08-02 04:50:31] environment-map.md generated (148 lines)`, finishing 25 seconds before vault-ingest's 4:55 commit.

---

Those are the five snags I actually hit. A single constraint โ€” "runs early in the morning under launchd" โ€” dumps a pile of problems on you that you'd never meet in normal script development: PATH, timeouts, Mermaid syntax, atomicity, race ordering. Each one I squashed made the script a little sturdier, and that's how it reached its current shape.

---

## Gotchas

The previous section dug into five big snags. Here I'll enumerate the "less dramatic but guaranteed" landmines. The combination of early-morning batch ร— launchd ร— Mermaid ร— Obsidian has many patterns that only break when specs that are individually harmless intersect.

- **macOS's `wc -l` prefixes output with spaces.** It outputs like `  148`, so embedding it directly into a Mermaid node label gives you `  148 jobs`. Get in the habit of always pairing `wc -l | tr -d ' '` everywhere you use a line count in a script. Looking at env-map.sh's code, `wc -l | tr -d ' '` appears in five or more places.

- **How to use `grep -vc`.** The auto skill count `ls ... | grep -vc README` is a one-liner meaning "count lines that don't contain README." Writing `grep -v README | wc -l` takes two commands; specifying count mode and the exclusion flag together with `grep -vc` does it in one. The pitfall: when there are zero files, `grep -c` returns exit 0 (outputting `0`), whereas `grep` (no match) returns exit 1. Misusing the flags changes the pipe's exit status, and combined with `pipefail` the script stops unexpectedly.

- **Not knowing `${NVM_BIN:+$NVM_BIN:}` expansion gives you a doubled colon in PATH.** Writing a plain `$NVM_BIN:` leaves a lone `:` before `/usr/local/bin` when `NVM_BIN` is an empty string. `:/usr/local/bin` means "prefer the current directory," which causes unintended executables to be invoked. The `${NVM_BIN:+$NVM_BIN:}` form is Bash parameter expansion meaning "expand `value:` only when NVM_BIN is non-empty." Always use this form when dynamically assembling PATH in a launchd script.

- **Multiple `StartCalendarInterval` entries only register the first one unless wrapped in `<array>`.** When setting multiple fire times in a plist, just listing `<dict>` elements causes the second and later ones to be ignored. Always wrap them in `<array>` and then list the `<dict>` elements. In com.shun.env-map.plist too, both the 4:50 and 8:10 entries sit inside an `<array>`. When you check the registered contents with `launchctl print gui/$(id -u)/com.shun.env-map`, always confirm that both times show up.

- **Forget `RunAtLoad: false` and it executes the instant you `launchctl load`.** Running on first load happens before you can verify PATH and the script's dependencies. com.shun.env-map.plist is explicitly `<false/>`, and after  I follow the procedure of running it manually with `launchctl kickstart gui/$(id -u)/com.shun.env-map` to confirm behavior before handing it to the schedule. A poor understanding of this flag is often behind "I loaded the plist, it ran immediately, it errored, and I can't figure out why."

- **The log file grows without bound.** launchd does not automatically rotate the files specified in `StandardOutPath` and `StandardErrorPath`. env-map.sh only writes one line per run, so growth is slow, but if errors become frequent `~/.claude/logs/env-map.launchd.log` can grow to several MB. Either run a monthly trim like `tail -100 ~/.claude/logs/env-map.launchd.log > /tmp/log.tmp && mv /tmp/log.tmp ~/.claude/logs/env-map.launchd.log` as a launchd job, or add it to your `newsyslog` configuration.

- **Mermaid's `classDef` doesn't take effect in Obsidian if written before the node definitions.** Per Mermaid's parser spec, `classDef` is recommended at the end of the graph definition. It's deliberate that the script emits `classDef dirty` and `classDef gone` after the project node loop. Swap the order and colors won't apply in some Obsidian versions.

- **Using `sort` instead of `sort -V` makes v9 > v10.** If you try to resolve the NVM binary path with `ls -d ... | sort | tail -1`, `v9.x.x` ends up after `v10.x.x` (in string sort, `9 > 1`). `sort -V` is a sort mode dedicated to version strings and evaluates numeric magnitude correctly. This is the crux of dynamic resolution that saves you from rewriting the plist every time you bump node.

- **`mktemp` temp files linger on SIGKILL.** On a normal exit, `mv "$TMP" "$OUT"` moves the temp file, but if launchd force-terminates the job (timeout or resource overrun), `$TMP` is left under `/tmp/`. A Mac clears `/tmp` on reboot, but in long-running always-on environments `/tmp/tmp.XXXXXX` files accumulate. Adding `trap 'rm -f "$TMP"' EXIT` at the top of the script makes the temp file disappear on any exit path.

- **Confusing heredoc quoting.** `<<HEADER` (unquoted) expands shell variables. `<<'HEADER'` (single-quoted) suppresses expansion. Places where you want to emit Mermaid backticks (code block openers) or pipe characters raw need the quoted form; places where you want to embed `$ISO` or `$OS_VER` need the unquoted form โ€” try to unify on one without being conscious of the two, and one of them breaks. env-map.sh uses both `<<HEADER` and `<<'PCT'`, and that distinction is what keeps the output consistent.

- **Not matching the plist's Label to its filename makes it hard to find with `launchctl list`.** Label is `com.shun.env-map`, filename is `com.shun.env-map.plist` โ€” break that rule and you can no longer exhaustively list things with `launchctl list | grep com.shun`. When managing 26 launchd jobs, consistency in the Label scheme is mandatory.

- **`ProcessType=Background` triggers App Nap and slows network responses.** macOS aggressively throttles CPU and network allocation for background processes. This is one reason the MCP connection running at 4:50 in the morning is more prone to timing out. Without the combination of a `timeout 12` cutoff and `?` degradation, this effect shows up as map generation failure.

- **When your Obsidian vault is under `git`, putting auto-generated files in `.gitignore`.** If you add environment-map.md to `.gitignore` thinking "it's auto-generated so it doesn't need git," you lose the ability to see day-over-day change in the diff. Tracking this file in git is precisely the mechanism that functions as a "change log," so it must never be added to `.gitignore`.

---

## Best practices

From everything above โ€” the implementation and the snags โ€” here are practices that generalize when building early-morning batches with launchd.

**โ‘  Commit to degrade design**

The baseline is a design where "the whole thing runs to completion even if one command fails." Drop `set -e` and standardize on the pattern of initializing each collection command with `?` before attempting it. Fetching the MCP connection count is the canonical example. Write every command with external dependencies in this form and you get a script that survives any environmental change.

**โ‘ก Use atomic writes via a temp file**

If you redirect directly to the output file, Obsidian will read a broken file mid-write. Writing all content to a temp file with `mktemp` and then overwriting with `mv` is the correct design. `mv` is atomic under the POSIX spec as long as it's on the same filesystem. Use it together with `trap 'rm -f "$TMP"' EXIT`.

**โ‘ข Insure PATH twice**

Write an initial PATH in the plist's `EnvironmentVariables`, and overwrite it dynamically at the top of the script. The plist is insurance for the worst case where launchd can't find any of its own tools; the script is insurance for keeping up with version upgrades. With only one of the two, you're left with the risk of "dying when the plist goes stale" or "dying on the minimal PATH right after launchd starts."

**โ‘ฃ Always wrap external commands in `timeout`**

Commands with unstable response times, like MCP, will block the entire script if called without a timeout. Set the response time ceiling to match the nature of the command. env-map.sh sets 12 seconds for `claude mcp list`. That pragmatic "if you can't answer within 12 seconds, we give up this round" is what creates stability in an early-morning batch.

**โ‘ค Leave a "ran to completion" trace on the log's last line**

At the end of the script, emit the line count and timestamp in the form `echo "[$(date '+%F %T')] generated ($(wc -l < "$OUT" | tr -d ' ') lines)"`. When you want to confirm "did it run today," you just look at the tail of the log. An extremely low line count (say, under 10) is a sign that generation ended partway. One simple line of text brings monitoring cost near zero.

**โ‘ฅ Define your Mermaid sanitize function first**

You can't avoid project names containing parentheses, hashes, or pipes. Define a `san()` function at the top of the script and commit to a design where all user input passes through it before being embedded in Mermaid. Leave even one place unsanitized and that string will break all of Mermaid.

**โ‘ฆ Set `RunAtLoad: false` and always run the first execution manually**

When  a new plist, set `RunAtLoad: false`, run it manually with `launchctl kickstart gui/$(id -u)/com.shun.<name>`, check the log, and only then hand it to the schedule. If the first run happens automatically, errors sometimes don't make it into the log depending on timing. Just making the first check manual dramatically shortens the "why isn't this working" investigation.

**โ‘ง Guarantee "runs quietly" with the LowPriorityIO + Nice + ProcessType trio**

The top priority for an early-morning batch is not getting in the way of anything else. Always include all three plist settings: `LowPriorityIO: true` (lower disk I/O priority), `Nice: 10` (lower CPU priority), `ProcessType: Background` (set macOS QoS to Background). Miss any one of them and it contends with your main work, making the Mac temporarily sluggish.

**โ‘จ Move fire times earlier than dependent jobs**

When multiple launchd jobs coordinate, the only way to guarantee "A completes before B" is to make A's fire time earlier than B's. Leave about five minutes of margin, and for the first run, line up both logs and check the timestamps. With env-map.sh firing at 4:50 and vault-ingest committing at 4:55, the logs record a 25-second gap between `[2026-08-02 04:50:31] generated` and vault-ingest's commit time. The important part is setting this based on measured margin, not on "roughly earlier."

**โ‘ฉ Put the output under the vault's git management to automate change records**

Put your script's output file in a git-managed directory and the daily commits become an "environment change log." Think of it as applying infrastructure-as-code thinking to script output. Being able to trace "lead-finder was clean until yesterday, now it's at 75" with git diff is only possible because of this placement. Rather than a dedicated log directory, leveraging a place that's already under git โ€” like an Obsidian vault โ€” gets you powerful change tracking at zero additional cost.

**โ‘ช Use `sort -V` for version comparison**

Don't use `sort` for nvm binaries, npm packages, or any version string sorting. If you want `v9 โ†’ v10` order, always use `sort -V` (version-aware sort). The idiom of taking the latest with `tail -1` presupposes `sort -V`.

**โ‘ซ Point stdout and stderr at the same log file**

Set the plist's `StandardOutPath` and `StandardErrorPath` to the same file. Splitting them into two produces the oversight "the script died with an error but nothing showed up in the stdout log." With a single file, one `tail -f ~/.claude/logs/env-map.launchd.log` shows you the whole situation.

**โ‘ฌ Standardize the project list format as `"display name|path"`**

Standardize `PROJECTS` array entries in the form `"display name|absolute path"`. Splitting display name and path into separate variables makes array management messy. Parsing is one line with the bash parameter expansions `entry%%|*` and `entry##*|`. It's a design where adding or removing a project is a change in this one array.

**โ‘ญ Make a habit of listing all jobs with `launchctl list | grep com.shun`**

With 26 jobs running, knowing "how many there are in total" is management basics. Since Label naming is standardized on `com.shun.*`, this grep extracts only your own jobs. The `launchd jobs: 26` figure in environment-map.md is the measured value from `ls ~/Library/LaunchAgents/com.shun.*.plist | wc -l`. Anything that's on disk as a plist but doesn't show up in `launchctl list` isn't loaded โ€” checking that gap periodically lets you catch "I thought I registered it but it was never actually running" early.

---

## Wrapping up

env-map.sh and com.shun.env-map.plist consist of a Bash script under 200 lines and a 45-line plist. But the `wiki/meta/environment-map.md` generated this morning (2026-08-02 08:10:05) records, as 148 lines of Mermaid diagrams and tables: the configuration of a MacBook Pro (Apple M5 / 24GB RAM), 26 launchd jobs, 47 plugins, 358 agents, 112 auto-skills, and the state of 11 projects โ€” that lead-finder has 75 uncommitted changes, that Closet OS and AETHERIA don't exist on disk.

Open Obsidian and you can survey the day's configuration; the git diff records what changed since yesterday. As a mechanism it's simple. But it's only once automation hit 26 jobs that the problem "without a map you can't tell what's alive" surfaced. I built this not because I want to manage things, but because I want to reach a state where I don't have to.

The foundation of a ยฅ1.2M/month autonomous environment lies less in the mechanism that earns and more in the mechanism that keeps the earning mechanism running. The more launchd jobs there are, the more that foundation matters. env-map writes the current position of 26 jobs into Obsidian every morning, putting me in a state where I can grasp the day's environment without opening a terminal.

The moment you feel your automation has "grown too big" is the optimal moment to build a map.

---

The full picture of the setup, the breakdown of the ยฅ1.2M/month, and a 30-day procedure are collected 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)*
โ”€โ”€ more in #developer-tools 4 stories ยท sorted by recency
โ”€โ”€ more on @claude code 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/a-4-minute-freeze-a-โ€ฆ] indexed:0 read:26min 2026-08-20 ยท โ€”