Automating a Daily Morning Health Check for Your Claude Code Setup with launchd
Production HTTP probes: 3 retries + a 15-second timeout
Vercel cold starts can delay the first request, so a single one-shot curl produces false alarms as 000 (timeout). Here's the implementation of probe_url():
probe_url () {
local name=$1 url=$2 code=000 attempt
# Vercel等のコールドスタートで初回が遅い→単発だと000/タイムアウトの誤報。
# 最大3回リトライ+timeout延長で潰す。
for attempt in 1 2 3 ; do
code=$( curl -s -o /dev/null -w "%{http_code}" --max-time 15 "$url" 2>/dev/null || echo 000 )
if [ "$code" = "200" ] || [ "${code:0:1}" = "3" ] ; then break ; fi
sleep 2
done
if [ "$code" = "200" ] || [ "${code:0:1}" = "3" ] ; then
echo "- ✅ **${name}**: ${code} (${url})"
else
echo "- ❌ **${name}**: ${code:-無応答} (${url})"
flag_red "${name} 本番が ${code:-無応答}"
fi
}
Only the combination of --max-time 15, sleep 2, and 3 retries finally eliminated the false alarms. In today's run (2026-07-24 08:10), all four production apps - the job‑hunting tracker, the job‑hunting navigator, the graduation planner, and AETHERIA RPG - came back 200 OK.
probe_autolike() - Gumroad billing flow
probe_autolike() goes all the way through the Gumroad billing flow. It hits the API with a dummy key: if the response is "license not found," things are healthy (the API is alive); if it's "server configuration error," the env vars are missing, which means Pro billing is dead.
probe_autolike () {
local product=$1 resp
resp=$( curl -s --max-time 8 -X POST https://autolike-license-server.vercel.app/api/verify \
-H "Content-Type: application/json" \
-d "{ \"key\": \"HEALTHCHECK-DUMMY\", \"product\": \"${product}\" }" 2>/dev/null )
if echo "$resp" | grep -q "サーバー設定エラー" ; then
echo "- ❌ **autolike/${product}**: サーバー設定エラー(env未設定→Pro課金死)"
flag_red "autolike/${product} がサーバー設定エラー"
elif echo "$resp" | grep -qE "ライセンスが見つかりません|valid" ; then
echo "- ✅ **autolike/${product}**: 課金フロー稼働(Gumroad到達OK)"
fi
}
Anything flagged RED gets collected into a "🚨 Needs attention" section at the top of the brief. Today it caught two items - a GitHub Scout scoring failure and a missing audit log in the affiliate auto‑poster - and I noticed both first thing in the morning.
Hook latency p95: aggregating JSONL with Python
Every hook execution appends one line - {"ts":..., "hook":..., "elapsed_ms":..., "exit_code":...} - to hook-latency.jsonl, and hook-latency-report.sh aggregates it.
python3 - "$JSONL" "$DAYS" << 'PY'
stats = collections.defaultdict(list)
...
for hook, vals in stats.items():
vals_sorted = sorted(vals)
n = len(vals_sorted)
p95 = vals_sorted[min(n-1, int(n*0.95))]
rows.append((hook, n, sum(vals_sorted)//n, p95, vals_sorted[-1], fail.get(hook, 0)))
rows.sort(key=lambda r: -r[3]) # p95 降順(遅いものを上に)
flag = " ⚠" if p95 > 1500 else ""
print(f"{hook:<32} {n:>5} {mean:>6}ms {p95:>6}ms {mx:>6}ms {fl:>5}{flag}")
PY
It flags anything with p95 > 1500ms with a ⚠ and sorts slowest‑first, so you can tell at a glance what's heavy. Here are today's actual numbers (last 24h, 356 records):
=== hook latency (last 1d, 356 records) ===
hook n mean p95 max fail
----------------------------------------------------------------------
skills-auto-update.sh 8 2030 404ms 3215 081ms 3215 081ms 0 ⚠
obsidian_context.sh 7 1618 35ms 15679ms 3194 281ms 0 ⚠
message_display_filter.sh 69 2374 0ms 9106ms 1469 891ms 0 ⚠
cost_guard.sh 68 1671 9ms 6789ms 1019 530ms 0 ⚠
user_prompt_submit.sh 70 5516ms 6426ms 3381 32ms 0 ⚠
model_routing_reminder.sh 70 901ms 5703ms 6386ms 0 ⚠
obsidian_context.sh shows a p95 of 15679ms (~16 seconds) and a mean of 61835ms (~1 minute), but the mean is dragged up by max outliers, so p95 is what I judge by. Since the columns are sorted by p95 descending, "higher up = heavier" reads at a glance.
launchd exit codes: every job in one line
launchctl list | grep com.shun | awk '{printf "- %s (last exit=%s)\n", $3, $2}' | head -15
Today's output (excerpt):
- com.shun.daily-brief (last exit=0)
- com.shun.vault-auto-ingest (last exit=0)
- com.shun.autopilot (last exit=1)
- com.shun.self-repair (last exit=0)
You can immediately see that only autopilot is at exit=1. The other 13 jobs were all exit=0. For details I go to ~/.claude/logs/com.shun.<name>.log, but the brief already narrows down where things are failing.
7-day API cost by model
run_to 60 ~/.claude/scripts/cost-summary.sh 7 2>&1 | head -10
Today's actual numbers (last 7 days):
=== cost summary (last 7d) ===
sessions : 1323
total : $11012.43
avg/sess : $8.324
per model :
claude-fable-5 : 56617 messages
claude-opus-4-8 : 43282 messages
claude-sonnet-5 : 1882 messages
claude-sonnet-4-6 : 632 messages
$11,012 over 7 days is within my Max plan's flat rate, but the per‑model message counts show where usage is skewed. fable‑5's 56,617 messages exceed opus‑4‑8's 43,282, which is useful input for tuning model routing.
run_to is a wrapper that keeps a hung child script from stalling the entire brief:
TIMEOUT_BIN="/opt/homebrew/bin/timeout"
run_to () {
local s=$1 ; shift
if [ -n "$TIMEOUT_BIN" ] ; then
"$TIMEOUT_BIN" --kill-after=15 "$s" "$@"
else
"$@"
fi
}
Both the hook latency report and the cost summary are wrapped in run_to 60.
Walking through personal projects' git status
for repo in \
~/dev/shukatsu-tracker \
~/dev/seo-affiliate-site \
~/Projects/autolike-license-server \
...
do
[ -d "$repo/.git" ] || continue
branch=$( git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null )
uncommitted=$( git -C "$repo" status --porcelain 2>/dev/null | wc -l | tr -d ' ' )
last_commit=$( git -C "$repo" log -1 --format='%cr %s' 2>/dev/null \
| cut -c1-70 | /usr/bin/iconv -f UTF-8 -t UTF-8 -c )
echo "- **${name}** [${branch}]: ${uncommitted} 未コミット - _${last_commit}_"
done
There's a reason for sanitizing with iconv -f UTF-8 -t UTF-8 -c: cut -c operates on bytes, so it can split UTF‑8 multibyte characters mid‑sequence, after which grep treats the file as binary and marker detection breaks - a footgun I hit myself. In today's run I could immediately see that lead-finder was sitting at 75 uncommitted changes with its last commit three weeks ago (it's in an intentional PAUSE state).
Idempotent design: comment markers prevent duplicate appends
daily-brief.sh runs twice, at 8:00 and 10:30, and sometimes vault-auto-ingest.sh has already created the file first. I needed a design that appends exactly once no matter the execution order.
MARK="<!-- daily-brief ${DATE} -->"
for f in \
"$HOME/Desktop/Daily Brief/today-brief-${DATE}.md" \
"$VAULT/wiki/briefs/daily/today-brief-${DATE}.md"
do
d=$( dirname "$f" )
[ -d "$d" ] || mkdir -p "$d" 2>/dev/null || { echo "WARN: $d 作成不可(TCC?)" ; continue ; }
# ファイルがなければ placeholder を作る
if [ ! -f "$f" ] ; then
{
echo "# 今日のブリーフ - $(date +%F)"
echo ""
echo "_(Claude生成のブリーフ本文は auto-ingest 完了時にこの上へ差し替わる)_"
} > "$f" 2>/dev/null || { echo "WARN: $f 作成不可(TCC?)" ; continue ; }
fi
# LC_ALL=C: 不正バイトがあっても grep がバイナリ扱いで見落とさないように
LC_ALL=C grep -qF -- "$MARK" "$f" 2>/dev/null && continue
{ echo "" ; echo "---" ; echo "" ; echo "$MARK" ; cat "$OUT" ; } >> "$f" 2>/dev/null \
|| echo "WARN: 合流追記失敗: $f"
done
LC_ALL=C forces grep into byte mode, which prevents the case where invalid bytes in the file make grep treat it as binary, return --, and miss the marker. In today's vault file, <!-- daily-brief 20260724 --> appears exactly once, and I confirmed the 10:30 re‑run was skipped.
Note: vault-auto-ingest.sh contains the same merge logic. Whichever runs first, a matching marker means the append is skipped, so nothing gets duplicated. The comment marker works as a "receipt" for the operation.
The TCC trap: /usr/bin/env bash fails
Since macOS Ventura, ~/Desktop and ~/Documents are protected by TCC (Transparency, Consent, and Control). When launchd starts your script, Full Disk Access is not inherited depending on the interpreter path.
<!-- ❌ /usr/bin/env 経由 → FDA未付与 → Desktop/Documentsへの書き込みがサイレントに失敗 -->
<key>ProgramArguments</key>
<array>
<string>/usr/bin/env</string>
<string>bash</string>
<string>~/.claude/scripts/daily-brief.sh</string>
</array>
<!-- ✅ /bin/bash 直起動 → FDA付与済みで動く -->
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>~/.claude/scripts/daily-brief.sh</string>
</array>
Going through /usr/bin/env makes the process count as one without FDA, and both mkdir -p and file writes fail silently. No error message, no files created - just empty logs. This trap once cost me a full week of missing briefs.
Note: The script's shebang #!/usr/bin/env bash can stay as is. Changing only the first element of the plist's ProgramArguments to /bin/bash fixes it.
plist design points
Here are the key parts extracted from the actual file:
<key>Label</key>
<string>com.shun.daily-brief</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>~/.claude/scripts/daily-brief.sh</string>
</array>
<!-- 8:00 と 10:30 の2回 -->
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>8</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Hour</key><integer>10</integer>
<key>Minute</key><integer>30</integer>
</dict>
</array>
<!-- ログイン時にも即実行 -->
<key>RunAtLoad</key>
<true/>
<!-- バックグラウンドジョブがClaude Codeの応答を遅らせないために -->
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
<!-- PATH を明示しないと homebrew/nvm/git が見つからない -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/path/to/.nvm/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
Making StartCalendarInterval an array lets you express multiple times in a single plist. If the Mac is asleep when a scheduled time passes, the job does not run at the next wake (that's just how launchd works). RunAtLoad: true compensates by also firing at login.
Note: ~ is not expanded inside a plist. Use absolute paths, or use $HOME inside the script.
Pitfalls I hit
- No FDA when going through
/usr/bin/env→ changed the first plist element to launch/bin/bashdirectly - False
000alarms from Vercel cold starts → fixed with 3 retries +--max-time 15+sleep 2 - Duplicate output from appending
|| echo 0togrep -c→grep -calready prints "0" on zero matches, so|| echo 0is unnecessary greptreating files as binary and missing markers withLC_ALLunset →LC_ALL=C grep -qFguarantees byte modecut -csplitting UTF‑8 and breaking downstreamgrep→ sanitize withiconv -f UTF-8 -t UTF-8 -c- A hung child script stalling the whole brief → wrap with
timeout --kill-after=15 60 <cmd> - Jobs skipped when the Mac sleeps through a scheduled time → compensated with
RunAtLoad
Summary
- Production HTTP probes use 3 retries +
--max-time 15to prevent false alarms from Vercel cold starts - Hook latency p95 is aggregated from JSONL with Python, flagging anything over 1500ms with ⚠ and sorting slowest‑first
launchdexit codes are checked across all jobs with one line:launchctl list | grep com.shun- 7‑day costs include per‑model message counts, feeding into model routing decisions
- Idempotent design with the
<!-- daily-brief YYYYMMDD -->marker means no duplicate appends regardless of order or run count - The TCC restriction is worked around by launching
/bin/bashdirectly in the plist
Next time, I'll cover the design of an assistant hook that automatically appends "yesterday's Claude Code work summary" to this brief.
Written by Lily - I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio · X · GitHub
Comments
No comments yet. Start the discussion.