Every automated posting lane on my machine sat at zero for an entire day, and top -o mem swore nothing was wrong. Swap was at 37GB. The actual culprit — dasd, holding 46GB of compressed memory behind a 264MB RSS — never appeared anywhere in the top 20. Once a guard script started running every 10 minutes, the same leak got reclaimed at 02:32 in the morning with swap back down to 4.1GB, and I found out from a log line instead of from a day of missing revenue.
I started at 100k yen/month as a university student, pushed it to 600k/month juggling multiple gigs, got laid off for reasons that had nothing to do with me and went back to zero, then spent six months rebuilding an autonomous Claude Code environment. It now runs at 1.2M yen/month in revenue. This is the story of the day that environment stopped for a full 24 hours.
Why this setup works
The false premise that "a process with small RSS is safe"
For years, memory monitoring on macOS was fine with just top -o mem. Sort by RSS descending, eyeball the list from the top, investigate anything over 500MB. That's how a lot of people operate, and I thought so too.
On August 9, 2026, that premise collapsed completely.
Every lane of my SNS auto-posting had produced zero posts for a full day. The Playwright instance managed by Claude Code looked like it was running. Going back through the logs, launchPersistentContext had been timing out at 180 seconds over and over. Hunting for the cause, the first thing I ran was top -o mem. Nothing suspicious in the top 20 processes. The largest RSS was a few GB, and dasd didn't appear anywhere in the list.
I noticed it when I checked swap usage on top's other screen. 37GB. Nearly double the physical RAM. With swap that inflated, any new memory request is essentially disk I/O. The moment Playwright tried to create a browser context, the OS started reading and writing swap to secure pages, and that blew past 180 seconds and failed.
So where was the culprit that pushed swap to 37GB? The vm.swapusage numbers were obviously abnormal, yet nothing showed up in RSS-sorted top.
Looking at the CMPRS column found the culprit
macOS top has a cmprs (compressed memory) column available via the -stats option. It's the mechanism where the OS zlib-compresses memory that isn't being actively used and packs it into RAM, keeping only the mapping to the actual pages — and this compressed region is not counted in RSS. It uses physical RAM but does not show up in RSS monitoring.
Running top -l 1 -n 20 -o mem -stats pid,command,mem,cmprs, dasd was there in the output.
PID COMMAND MEM CMPRS
...
1842 dasd 264M 46G
The RSS-equivalent MEM is 264MB. That's exactly why it could never crack the top 20 of an RSS-descending list. But CMPRS is 46GB. That is the cause of the 37GB of swap. dasd (Duet Activity Scheduler, macOS's background task arbitration daemon) had accumulated 46GB of compressed memory in 21 hours of uptime, and the OS had no option left but to escape into swap.
Why I fix the "environment," not the "task"
The reality behind 1.2M yen/month is automation scripts and Claude Code running continuously in parallel. Posts go out to social while I'm asleep, the funnel keeps turning, and by the next morning the results are compiled. When that machinery stops for a day, the damage lands directly on revenue.
Restarting dasd by hand takes five minutes. The problem is that it will stop again. I actually left a comment noting that this symptom "occurs after 21 hours of uptime." If the same thing happens the next day and the day after, that creates a daily chore of checking top every morning and typing sudo killall dasd. That's a "task," not an "environment."
The iron rule of an autonomous environment is "it's already fixed even if I never noticed." A monitoring script runs every 10 minutes, restarts things when they cross a threshold, and a notification lands in Discord. When I look at the log the next morning and it says "reclaimed at 02:32 AM," that's the environment behaving correctly.
On top of that, dasd wasn't the only problem. iii (agentmemory, the Node.js process that manages the agent's session memory) existed as a second leaker, growing at roughly 4GB per hour. With two leaks happening at once, a setup that assumes manual response hits its limit.
What it means to threshold on CMPRS instead of RSS
Threshold design matters too. dasd's RSS is 264MB — an RSS-based threshold would never trigger. So I use the value in the mem column, which sums CMPRS and MEM (≒RSS).
The script's to_mb() function handles that.
to_mb() {
awk -v v="$1" 'BEGIN {
u = substr(v, length(v), 1); n = substr(v, 1, length(v) - 1) + 0
if (u == "T") { printf "%.0f", n * 1024 * 1024 }
else if (u == "G") { printf "%.0f", n * 1024 }
else if (u == "M") { printf "%.0f", n }
else if (u == "K") { printf "%.0f", n / 1024 }
else { printf "%.0f", v / 1048576 }
}'
}
It converts what top returns — "47G" or "264M" — into numeric MB and compares against the threshold. Because dasd shows "47G" in the MEM column (a virtual total that includes CMPRS), a 5120MB (5GB) threshold detects it correctly. At 264MB of RSS, it would slip past forever.
The overall flow
System diagram
┌────────────────────────────────────────────────────────────┐
│ launchd (com.lily.mem-hog-guard) │
│ 毎時 :02 :12 :22 :32 :42 :52 に起動(10分間隔) │
└──────────────────────┬─────────────────────────────────────┘
│
▼
top -l 1 -n 20 -o mem \
-stats pid,command,mem,cmprs
│
▼ awk で PID行以降を抽出
┌────────────────────────────┐
│ pid command mem cmprs │ ← MEM列 = CMPRS込み
└────────────┬───────────────┘
│ プロセスごとにループ
▼
┌─────────────────────────────────────────────┐
│ NEVER_TOUCH に含まれる? │
│ kernel_task / WindowServer / launchd / Finder │
│ YES → スキップ │
└──────────────────────┬──────────────────────┘
│ NO
▼
┌──────────────────────────────────────────────┐
│ AUTO_RESTART_NAMES に含まれる?(dasd) │
│ かつ MEM ≥ 5120MB │
│ かつ クールダウン未満(30分)でない │
│ YES → sudo killall / launchd が自動復活 │
└──────────────────────┬───────────────────────┘
│ NO
▼
┌──────────────────────────────────────────────┐
│ LAUNCHD_RESTART_MAP に含まれる?(iii) │
│ かつ MEM ≥ 2048MB │
│ YES → TERM → 2秒待ち → KILL │
│ → kickstart -k │
└──────────────────────┬───────────────────────┘
│ NO
▼
┌──────────────────────────────────────────────┐
│ MEM ≥ 6144MB │
│ YES → Discord通知のみ(触らない) │
└──────────────────────────────────────────────┘
│ 全プロセス処理後
▼
findings があれば Discord通知
swap使用量を添付
plist: the mechanism that fires every 10 minutes
<key>StartCalendarInterval</key>
<array>
<dict><key>Minute</key><integer>2</integer></dict>
<dict><key>Minute</key><integer>12</integer></dict>
<dict><key>Minute</key><integer>22</integer></dict>
<dict><key>Minute</key><integer>32</integer></dict>
<dict><key>Minute</key><integer>42</integer></dict>
<dict><key>Minute</key><integer>52</integer></dict>
</array>
StartInterval (polling by seconds) would give the same frequency at 600 seconds, but I chose StartCalendarInterval with minute-level specification. The reason is simple: log timestamps land consistently at ":02" and ":12", which makes it easy to eyeball "what time was it reclaimed?" after the fact. RunAtLoad is set to false. Running right after load, immediately after system boot, hits a point where swap hasn't stabilized, so the first 10 minutes are a wait-and-see.
Nice is set to 10 (low priority) and LowPriorityIO to true. It would defeat the purpose if the monitoring script itself ate CPU and I/O. top -l 1 is light, but just in case, I explicitly tell the OS scheduler that background priority is fine.
The core part: parsing top's output with awk
The heart of the script is the awk processing that picks up only the lines after the PID header from top -l 1 -n 20 -o mem -stats pid,command,mem,cmprs.
TOP_RAW=$(top -l 1 -n 20 -o mem -stats pid,command,mem,cmprs 2>/dev/null)
while read -r pid command mem cmprs; do
[ -n "$pid" ] || continue
case "$pid" in ''|*[!0-9]*) continue ;; esac
mem_mb=$(to_mb "$mem")
cmprs_mb=$(to_mb "$cmprs")
...
done <<<"$(awk '/^PID/ { seen = 1; next }
seen && NF >= 4 {
cmprs = $NF; mem = $(NF - 1); pid = $1
name = ""
for (i = 2; i <= NF - 2; i++) name = name (name == "" ? "" : " ") $i
gsub(/ /, "", name)
print pid, name, mem, cmprs
}' <<<"$TOP_RAW")"
awk's /^PID/ detects the header line and only the lines after it get processed. Because process names can contain spaces, like "Google Chrome Helper," field NF-2 and NF-1 are mem and cmprs, field 1 is pid, and fields 2 through NF-2 are all joined into the process name. Finally gsub(/ /, "", name) strips the spaces so the downstream shell in_list() function can do a simple string comparison.
There's also a lock to prevent double execution. Since it fires every 10 minutes, if a previous run drags on for some reason (a case where sudo killall times out, for example), there's a risk of going to kill the same PID twice.
LOCK_FILE="/tmp/com.lily.mem-hog-guard.lock"
if ! /usr/bin/shlock -f "$LOCK_FILE" -p "$$"; then
exit 0
fi
trap '/bin/rm -f "$LOCK_FILE"' EXIT HUP INT TERM
shlock is macOS's standard atomic lock-file creation command; -f specifies the lock file path and -p the PID. If a still-valid PID already holds the lock, it fails and immediately does exit 0. trap catches EXIT/HUP/INT/TERM to release the lock.
Why the restart method differs by process type
How a detected process gets handled branches on whether the OS manages that process.
The dasd case (AUTO_RESTART)
dasd is a root-owned macOS daemon that launchd manages at all times. Run sudo killall dasd and launchd immediately brings up a new process. Nothing needs to be configured on the user side — just killing it completes the "restart."
if sudo -n /usr/bin/killall "$command" 2>/dev/null; then
log "restarted $command pid=$pid mem=$mem cmprs=$cmprs"
sudo -n is non-interactive mode, which doesn't request an interactive password prompt. It fails in environments without a NOPASSWD rule in sudoers, but in that case it falls back to a Discord notification prompting manual action. Notifying and leaving the judgment to the operator is safer than automation failing heavy-handedly.
The iii (agentmemory) case (LAUNCHD_MAP)
iii is a Node.js process running as a user launchd job. Here, the first approach I tried — kickstart -k alone — failed.
What I measured (2026-08-09) is that iii detaches and forks itself at startup, so even when launchd swaps out the parent process (node), the old process survives as an orphan with PPID=1. The old process kept running while holding its memory, a new process was simply added, and total memory usage actually increased.
The correct order is as follows.
kill -TERM "$pid" 2>/dev/null || true
sleep 2
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/$label" >/dev/null 2>&1 || true
sleep 3
if kill -0 "$pid" 2>/dev/null; then
log "restart-failed $label pid=$pid が残存"
The order is TERM → wait 2 seconds → KILL → kickstart. First attempt a graceful shutdown with TERM, check liveness after 2 seconds with kill -0, and if it's still there, force-terminate with KILL. Then have launchd start a new process with kickstart -k. Finally, wait 3 seconds and confirm the old PID isn't left behind; if it is, record restart-failed in the log.
Cooldown design: from one global file to per-process
In the first implementation, cooldown was managed globally with a single file.
# 旧実装(バグあり)
COOLDOWN_FILE="$STATE_DIR/.mem-hog-last"
That caused a problem on 2026-08-09. Right after reclaiming dasd at 16:42 and recording the cooldown, iii had swollen to 1.5× its threshold (3.1GB) — and because the global cooldown was active, it wasn't reclaimed at all for 30 minutes. A design where fixing one thing leaves the others untouched for 30 minutes is wrong.
After the fix, the structure changed to holding a cooldown file per process.
COOLDOWN_DIR="$STATE_DIR/cooldown"
cooldown_active() {
local key="$1" file last
file="$COOLDOWN_DIR/$(printf '%s' "$key" | tr -c 'A-Za-z0-9._-' '_')"
[ -f "$file" ] || return 1
last=$(cat "$file" 2>/dev/null)
case "$last" in ''|*[!0-9]*) return 1 ;; esac
[ $(( $(date +%s) - last )) -lt "$COOLDOWN_SEC" ]
}
cooldown_mark() {
local key="$1"
date +%s >"$COOLDOWN_DIR/$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_')"
}
The process name (or launchd label) is used as the file name. tr -c 'A-Za-z0-9._-' '_' converts it to only characters that are safe in a file name. Even during the 30 minutes that dasd's cooldown is running, iii's cooldown file is independent, so if iii crosses its threshold, reclamation runs on the very next 10-minute cycle.
The NEVER_TOUCH list is also defined explicitly.
NEVER_TOUCH="kernel_task WindowServer launchd loginwindow Finder"
kernel_task is the special process macOS uses for CPU thermal throttling; kill it and the system freezes. WindowServer manages the entire GUI, and taking it down makes the screen disappear. These are never touched no matter how far above the threshold they swell. They fall into the NOTIFY_ONLY category (above 6144MB), which only notifies and leaves the judgment to a human.
Implementation details
Set PATH explicitly in the launchd context
The top of the script looks like this.
set -uo pipefail
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
PATH is limited to four directories because the PATH inherited by a process launched from launchd is only /usr/bin:/bin. Whatever is written in ~/.zshrc, whatever tools Homebrew placed in /opt/homebrew/bin, whatever Node.js nvm manages — none of it is visible from launchd.
The commands this script calls are only top, awk, sysctl, killall, launchctl, and shlock. All of them live in /usr/bin or /usr/sbin. It's a design of "carry only what you use, for certain" rather than "carry everything you might want." You can also see it as guaranteeing that nothing outside those four directories — meaning no runtime under ~/.nvm — ends up on PATH.
Why I use set -uo pipefail and avoid set -e
With set -e, the script dies instantly on any command that returns a non-zero exit code. There are situations where that's not correct.
kill -TERM "$pid" 2>/dev/null || true
sleep 2
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
kill -0 is a liveness check. The target process being gone 2 seconds after TERM took effect is the normal path, and in that case kill -0 returns exit code 1. Under set -e, even with || true written, the behavior of "is the error actually absorbed?" shifts subtly depending on the conditional or subshell expansion context, which makes it hard to reliably express "failure is acceptable here."
set -u (error on referencing an undefined variable) and pipefail (treat the whole pipeline as failed if a mid-pipe command fails) are necessary. So I chose only set -uo pipefail and take the approach of explicitly marking permissible failure points with || true. The numeric check case "$pid" in ''|*[!0-9]*) continue ;; esac is likewise a guard against the risk of dying on an empty-string reference now that set -u is in play.
Parsing LAUNCHD_RESTART_MAP
So that multiple jobs can be configured with a single environment variable, I adopted the format "process_name=launchd_label:threshold_MB".
LAUNCHD_RESTART_MAP="${MEM_HOG_LAUNCHD_MAP:-iii=com.lily.agentmemory:2048}"
The parsing part looks like this.
for m in $LAUNCHD_RESTART_MAP; do
case "$m" in "$command="*) entry="${m#*=}"; break ;; esac
done
label="${entry%:*}"
limit="${entry##*:}"
${m#*=} is POSIX-compliant parameter expansion that removes everything before = with the shortest match. It cuts com.lily.agentmemory:2048 out of iii=com.lily.agentmemory:2048. Next, ${entry%:*} removes everything after : with the shortest match to give com.lily.agentmemory, and ${entry##*:} removes everything before : with the longest match to give 2048.
% is "from the back, the shorter side," and ## is "from the front, the longer side." Launchd labels are dot-separated like com.foo.bar.baz and the naming convention contains no colons, so this expansion works reliably. Separating with spaces lets you line up multiple entries, but the only thing currently under management is iii, so there's one entry.
Verifying thresholds against real data in DRY_RUN mode
case "${1:-}" in
'') ;;
--dry-run) DRY_RUN=1 ;;
*) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;;
esac
Before loading it into launchd, I run bash ~/.claude/scripts/mem-hog-guard.sh --dry-run. It shows what would be triggered against the current memory state, without any actual kills.
WOULD RESTART: dasd (pid=1842) が MEM 47G/圧縮 46G を抱えている -> 再起動
swap: total = 40.00G used = 37.12G free = 2.88G (encrypted)
dry-run 終了
You can confirm against real data — before deploying to production — whether the thresholds are right, whether NEVER_TOUCH is working, and whether any unexpected process is being targeted. Loading it into launchd without this check risks something getting killed unintentionally on the next 10-minute cycle. Always look at the current state with --dry-run before setting thresholds — that's the basic procedure for this kind of script.
Accumulating notifications and sending once at the end
findings=""
# ループ内:
findings="${findings}✅ ${msg}"$'\n'
# ループ後:
if [ -n "$findings" ]; then
swap=$(sysctl -n vm.swapusage | sed 's/vm.swapusage: //')
notify "🧠 mem-hog-guard
$findings
swap: $swap"
fi
findings is built up by string concatenation inside the loop, and it's thrown to Discord exactly once after the scan of all processes finishes. Even if multiple processes cross the threshold in the same 10-minute cycle, it's one API call. $'\n' uses bash's $'...' notation to produce a literal newline. Mixing a newline into a plain double-quoted string can break escaping depending on the expansion context, which is why this notation is used.
Swap usage is fetched after the full scan with sysctl -n vm.swapusage (-n for the value only). sed 's/vm.swapusage: //' strips the key portion so only the numeric string goes into the notification body.
Where I got stuck
Trying kickstart -k alone made memory go up
My first attempt at handling iii was to run only launchctl kickstart -k "gui/$(id -u)/com.lily.agentmemory". Checking immediately afterward with ps aux | grep iii, two PIDs were lined up. The new process had come up while the old one remained, and total memory usage was higher than before the reclaim.
The cause is iii's startup pattern. iii starts as a Node.js process, but after initialization it detaches and forks itself. The "parent process" that launchd knows about is the short-lived pre-fork node process, and kickstart replaces only that parent. The resident process that persists after the fork ends up with PPID=1 (an orphan directly under launchd), which puts it outside launchd's job management.
That history is left as a comment in the code as well.
# kickstart だけでは足りない。実測(2026-08-09): launchd は親(node)を入れ替えるが
# iii は detach して spawn されるため ppid=1 の孤児として生き残り、
# メモリを抱えたまま新プロセスが増えるだけだった。先に本体を落とす。
kill -TERM "$pid" 2>/dev/null || true
sleep 2
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/$label" >/dev/null 2>&1 || true
sleep 3
if kill -0 "$pid" 2>/dev/null; then
log "restart-failed $label pid=$pid が残存"
The correct order is "first take down the real process with TERM, wait 2 seconds and check liveness with kill -0, force-terminate with KILL if it's still there, and then have kickstart start the new process." Run kickstart first and you end up in a double-run state where "from launchd's perspective a new process is running" while "a PPID=1 old process is also running." I added the final sleep 3 and kill -0 to check for a leftover old PID, logging restart-failed if it's still there — and I added that verification step precisely because of that first failure, where I thought I'd reclaimed memory but had actually increased it.
Reclaiming at 16:42 left iii untouched for 30 minutes
The initial cooldown implementation was a single file shared by all processes.
# 旧実装
COOLDOWN_FILE="$STATE_DIR/.mem-hog-last"
At 16:42 on August 9, 2026, a dasd reclaim ran and a timestamp was recorded in the cooldown file. Immediately after, iii had swollen to 3.1GB (about 1.5×) against its 2048MB threshold. But on the 16:52 cycle the global cooldown was judged active, so the iii reclaim was skipped. The same thing repeated at 17:02 and 17:12, and iii was left alone for 30 minutes.
I had implemented cooldown with the intent of "wait and see a bit since it was just fixed," but it backfired into "reclaiming dasd stops iii from being reclaimed." I only noticed this the next morning, going back through the logs. I left the comment 16:42にdasdを回収した30分間は iii が閾値の1.5倍(3.1GB)まで膨らんでも一切回収されなかった in the code so that the next person to read it doesn't revert to the same design.
After the fix, the process name (or launchd label) becomes the file name, giving each an independent cooldown.
COOLDOWN_DIR="$STATE_DIR/cooldown"
cooldown_active() {
local key="$1" file last
file="$COOLDOWN_DIR/$(printf '%s' "$key" | tr -c 'A-Za-z0-9._-' '_')"
[ -f "$file" ] || return 1
last=$(cat "$file" 2>/dev/null)
case "$last" in ''|*[!0-9]*) return 1 ;; esac
[ $(( $(date +%s) - last )) -lt "$COOLDOWN_SEC" ]
}
tr -c 'A-Za-z0-9._-' '_' converts a launchd label (com.lily.agentmemory) straight into a file name. dasd's cooldown file is dasd and iii's is com.lily.agentmemory, existing completely independently. Even if both cross their thresholds at the same time, each is controlled independently by its own timer.
The script dies instantly when launched from launchd
The initial version didn't set PATH explicitly. Manual runs from the terminal worked fine, but the scheduled launches by launchd failed every time. Checking with log show --predicate 'process == "mem-hog-guard"', the script was dying instantly with exit code 127. 127 is the exit code a shell returns for "command not found."
The PATH a launchd-started process has is only /usr/bin:/bin. The settings in ~/.zshrc are completely ignored. Tools Homebrew placed in /opt/homebrew/bin and the Node.js nvm manages are invisible from launchd.
The problem is that this absolutely never reproduces while you're testing in the terminal. Starting zsh loads .zshrc and puts things on PATH, so the manual run succeeds. It only fails once you're in the launchd context. When writing launchd scripts, the premise "it worked in the terminal, so it works under launchd" does not hold.
The current script puts PATH="/usr/bin:/bin:/usr/sbin:/sbin" on line 2 because I confirmed that the commands it uses (top, awk, sysctl, killall, launchctl, shlock) exist only in /usr/bin and /usr/sbin, and then declared exactly those four directories. Since adding those two lines, there hasn't been a single PATH-caused failure in the scheduled launchd runs. It's now the first thing I check when writing a launchd script — reflexively.
Gotchas
Beyond the three failures detailed above (orphans left behind by kickstart alone, 30 minutes of neglect from a global cooldown, and PATH-induced 127 deaths under launchd), here's a full list of points I snagged on during implementation.
plist design
Setting
RunAtLoad truemakes it run right after OS boot. Swap statistics haven't stabilized right after boot, so it can misjudge normal values as "high load" and restart a daemon. Set<false/>explicitly.Writing
StandardOutPath/StandardErrorPathas relative paths means launchd can't write them. launchd's working directory is root (/). Tilde expansion for~/.cache/...doesn't work either, so hard-code full paths. Since the plist loads successfully even though no logs are ever written, the state of "it's running but nothing gets recorded" is hard to notice.StartInterval 600(second-based polling) produces up to a 10-minute gap after an OS restart. WithStartCalendarInterval's minute-level specification (:02/:12/:22/:32/:42/:52), it reliably fires as soon as the next ":XX minute" arrives, even after a reboot. Log times are also fixed at "every hour at :02," which makes it easy to eyeball "what time was it reclaimed?" in the log the next morning.Omitting
NiceandLowPriorityIOmakes the OS scheduler treat it as foreground. Having a monitoring script that fires every 10 minutes cut into the I/O queue defeats the purpose. Put inNice 10andLowPriorityIO trueat minimum.Forgetting
gsub(/ /, "", name)inawkmakes comparisons for process names with spaces fail forever.Google Chrome Helper (Renderer)gets split into fields at the point ofwhile read -r pid command mem cmprs, leavingcommandas justGoogle. Strip the spaces inside awk, join into a single string, then pass it toin_list().Removing the
case "$pid" in ''|*[!0-9]*) continue ;; esacguard passes header remnants and blank lines as arguments tokill. There are cases where the-n 20output gets mangled by environment ortopversion differences, so the pure numeric check can't be dropped.Without
trap '/bin/rm -f "$LOCK_FILE"' EXIT HUP INT TERM, a mid-run death leaves the lock file behind. On the next cycleshlockjudges that "the previous process is alive" and keeps skipping everything. Nothing happens, but no notifications arrive either, so you notice even later.Running
kickstartright afterkill -TERMleaves old and new processes running side by side. Not following the order TERM → wait 2 seconds →kill -0liveness check → KILL → kickstart turns a supposed reclaim into a double run of "old process + new process," and memory goes up.
Thresholds and detection
-n 20only fetches the top 20. Since the list is sorted by MEM descending, a process with small RSS and enormous CMPRS gets buried lower down. If the target process is known, raise it to-n 50or supplement afterward withps -eo pid,comm,rss.Emptying
NEVER_TOUCHmakeskernel_taska kill candidate.kernel_taskis a special process the OS uses for CPU thermal throttling; killing it freezes the system. Always define those five —kernel_task WindowServer launchd loginwindow Finder— as a minimum set. Identify the processes you must never take down in your own environment ahead of time withDRY_RUNand add them.sudo -n killall dasdfails silently in environments without a NOPASSWD rule insudoers. In that case the script writesrestart-failed(no-sudo)to the log and falls back to a Discord notification with "manual:sudo killall dasd" attached. Before deploying, grant NOPASSWD viasudo visudolimited to/usr/bin/killallonly. Widening it to something likeALL=(ALL) NOPASSWD: ALLcreates a different risk.Don't load into production launchd without
DRY_RUN. Runningbash ~/.claude/scripts/mem-hog-guard.sh --dry-runprints "WOULD RESTART" and "WOULD NOTIFY" against the current state. Confirm no unintended process is being caught and that the thresholds are reasonable against real numbers before youlaunchctl load.If the notification script (
~/.discord/notify.sh) lacks the execute bit (x), it operates silently with no notifications. Thenotify()function checks permissions with[ -x "$NOTIFY_SCRIPT" ]and quietly doesreturn 1if absent. No error appears, but no notification arrives either. Check thexbit withls -la ~/.discord/notify.sh.
Best practices
1. Set thresholds on the mem column, which includes CMPRS
The mem column of top -stats pid,command,mem,cmprs is a total that includes CMPRS. Monitoring by RSS alone means a process with 264MB RSS and 46GB CMPRS can never be detected. Converting all units — T/G/M/K — with a to_mb() function and comparing thresholds in numeric MB is the minimal, reliable design.
2. Fix PATH on line 2 of a launchd script
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
After adding these two lines, there hasn't been a single PATH-caused failure in scheduled launchd runs. Confirm first that the commands you use (top, awk, sysctl, killall, launchctl, shlock) all live in /usr/bin or /usr/sbin, then declare only those four directories. "It worked in the terminal, so it works under launchd" does not hold.
3. Use only set -uo pipefail, and mark permissible failures with || true
set -e has cases where the script dies on the exit code 1 from kill -0 (a process liveness check). Catch only undefined-variable references and mid-pipe failures with set -uo pipefail, and attach || true to commands that are allowed to fail so the intent is clear.
4. Manage cooldowns with an independent file per process
COOLDOWN_DIR="$STATE_DIR/cooldown"
file="$COOLDOWN_DIR/$(printf '%s' "$key" | tr -c 'A-Za-z0-9._-' '_')"
Because dasd and iii have independent cooldowns, neither gets left alone for 30 minutes when both cross their thresholds at the same time. The tr -c 'A-Za-z0-9._-' '_' used when turning a process name into a file name also safely converts a dot-separated launchd label into a file name.
5. For restarting launchd-managed processes, keep the order "TERM → wait 2s → KILL → kickstart → wait 3s → check for leftovers"
The meaning of the order is clear. Attempt a graceful shutdown with TERM. Check liveness after 2 seconds with kill -0, and force-terminate with KILL if it's still there. Then have kickstart start the new process. Finally, wait 3 seconds and confirm the old PID isn't left behind, logging restart-failed if it is. Firing kickstart first gives you an old/new double run.
6. Prevent double execution with shlock
LOCK_FILE="/tmp/com.lily.mem-hog-guard.lock"
if ! /usr/bin/shlock -f "$LOCK_FILE" -p "$$"; then exit 0; fi
trap '/bin/rm -f "$LOCK_FILE"' EXIT HUP INT TERM
shlock is macOS's standard atomic lock-creation command. It prevents going to kill the same PID twice when a script firing at 10-minute intervals overlaps with its previous run. Forget the trap and a mid-run death leaves the lock behind, causing every subsequent cycle to skip everything.
7. Define a NEVER_TOUCH list explicitly and check what gets caught in advance with DRY_RUN
Define at least five processes (kernel_task WindowServer launchd loginwindow Finder), run --dry-run, and confirm no unintended process shows up under "WOULD RESTART" before loading into launchd. Setting thresholds too low can make everyday apps into targets.
8. Confirm NOPASSWD is configured before deploying sudo -n killall
Limit the sudoers addition to just the /usr/bin/killall command. Since launches from launchd happen outside a login session, a normal sudo that prompts for a password won't work. If NOPASSWD isn't configured, a notification fallback is required — and the script implements this by default. Before deploying, check whether sudo -n /usr/bin/killall --help succeeds (if it returns immediately without error, you have permission).
9. Send notifications once, together, after the loop
findings="${findings}✅ ${msg}"$'\n' # ループ内: 積み上げるだけ
# ループ後:
if [ -n "$findings" ]; then notify "🧠 mem-hog-guard\n${findings}\nswap: $swap"; fi
Even if multiple processes cross their thresholds at once, it's one API call. Swap usage is fetched after the full scan with sysctl -n vm.swapusage (-n for the value only) and attached. If findings is empty, no notification is sent at all, so a quiet, normal cycle produces no Discord notification.
10. Keep both local logs and Discord notifications
The log records the timestamp, PID, and memory amount, like [2026-08-11 02:32:17] restarted dasd pid=1842 mem=47G cmprs=46G. Discord notifications exist for immediacy; local logs exist for traceability. A discrepancy like "I got the notification but nothing was actually reclaimed (the old process survived)" can be discovered from restart-failed in the log.
11. Cover all of T/G/M/K in to_mb()
The current dasd returns "47G," but include awk branches for all four units so that thresholds compare correctly against a future process that returns "1.2T" or "512K." Miss one unit and a 0 or an empty string enters the comparison, causing an unintended trigger (or detection that never happens).
12. Don't remove the orphan-leftover check step
After kickstart, sleep 3 and check with kill -0 "$pid" whether the old PID is gone. If it's still there, record restart-failed in the log. Even when a "reclaim complete" notification arrives, only this verification step tells you the fact that old and new processes were running double. I had left this out on day one of the implementation, on August 9, 2026, which is why it took several cycles to notice the fact that memory had gone up after kickstart.
Summary
RSS-descending monitoring with top -o mem has a structural blind spot. Because macOS compressed memory (CMPRS) isn't counted in RSS, a dasd holding 46GB of compressed memory at 264MB of RSS appears nowhere in the list. That blind spot produced a state of 37GB of swap, Playwright's launchPersistentContext kept timing out at 180 seconds, and a full day of SNS auto-posting went to zero. When an environment running at 1.2M yen/month in revenue stops for a day, that's damage, straight up.
mem-hog-guard.sh solves three problems: detect via the mem column including CMPRS (RSS lets it slip past forever), change the restart method according to what manages the process (OS daemons need only killall; user launchd jobs require the order "TERM → KILL → kickstart"), and make cooldowns independent per process (a single global file leaves one of multiple leaks untouched for 30 minutes).
Run it every 10 minutes with launchd's StartCalendarInterval, and hold it to background priority with Nice 10 and LowPriorityIO true. Prevent double execution with a lock file, prevent repeated restarts with cooldowns, and verify against real data with --dry-run before loading into production. Put all of that together and you get an environment where "it's already fixed even if I never noticed." When I open the log the next morning and it says "reclaimed at 02:32 AM, swap down to 4.1GB," that night's revenue was protected.
I've written up the full picture of the setup, the breakdown of the 1.2M yen/month, and a 30-day procedure in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)