Nobody plans for log files. You wire up a background job, it works, and six weeks later a directory you never look at is quietly eating your disk.
In my previous post on automating self-audits I built the skeleton of a nightly job. This post is about the side effect of that: the logs my resident agents keep spewing grew without bound, and how I designed a trimming script wired into launchd to keep things quiet.
Once you have 20+ agents running as residents, logs pile up by hundreds of KB per day. Measured this morning (right after the 03:45 automated run), ~/.claude/logs/
was 20M, and 21M as of now (10:43). 188 files. It was only a matter of time before it crossed 22MB.
The more launchd jobs there are, the more logs there are. Looking at the representative large files makes it obvious.
5.7M agentmemory.err.log
3.8M vault-auto-ingest.log
3.1M hook-latency.jsonl
2.7M cost-log.jsonl
728K dotfiles-snapshot.log
Every one of them just streams errors and progress forever, and I almost never read the old lines. All I need is "what happened recently." And yet the logs keep accumulating, and the number from du -sh ~/.claude/logs/
jumps every day.
Plain deletion is a problem — if the log is empty when I'm chasing down a recent error, I'm stuck. So instead of deleting, I went with keeping only the tail.
The policy of log-rotate.sh
fits in three lines.
tail -n 2000
and overwriteI use the word "rotate," but it isn't rotation. It just truncates the file. No backups either. The latest 2000 lines are plenty to trace a recent failure.
Here is the full text of ~/.claude/scripts/log-rotate.sh
. It fits in 60 lines.
#!/bin/bash
set -uo pipefail
LOGDIR="$HOME/.claude/logs"
SELF_LOG="$LOGDIR/log-rotate.log"
MAX_BYTES=$((5 * 1024 * 1024))
KEEP_LINES=2000
echo "[$(date '+%F %T')] rotate start" >> "$SELF_LOG"
find "$LOGDIR" -maxdepth 1 -type f \( -name '*.log' -o -name '*.err' -o -name '*.out' -o -name '*.jsonl' \) | while read -r f; do
sz=$(stat -f%z "$f" 2>/dev/null || echo 0)
if [ "$sz" -gt "$MAX_BYTES" ]; then
tail -n "$KEEP_LINES" "$f" > "$f.tmp" && mv "$f.tmp" "$f"
echo "[$(date '+%F %T')] truncated $(basename "$f") ($sz bytes -> $(stat -f%z "$f") bytes)" >> "$SELF_LOG"
fi
done
find "$LOGDIR" -maxdepth 1 -type f \( \
-name 'daily-brief-2*.md' -o -name 'project-health-2*.md' -o \
-name '.*-done-*' -o -name '.vault-ingest-*' -o -name '*.plist.bak*' \
\) -mtime +30 -delete 2>/dev/null
sz=$(stat -f%z "$SELF_LOG" 2>/dev/null || echo 0)
[ "$sz" -gt "$MAX_BYTES" ] && tail -n "$KEEP_LINES" "$SELF_LOG" > "$SELF_LOG.tmp" && mv "$SELF_LOG.tmp" "$SELF_LOG"
echo "[$(date '+%F %T')] rotate done (dir=$(du -sh "$LOGDIR" | cut -f1))" >> "$SELF_LOG"
Truncation results are recorded in log-rotate.log
. Here's actual output.
[2026-07-12 03:45:05] truncated agentmemory.err.log (5449070 bytes -> 331336 bytes)
[2026-07-19 03:45:05] truncated hook-latency.jsonl (5376787 bytes -> 178061 bytes)
[2026-07-19 03:45:05] truncated vault-auto-ingest.log (5255158 bytes -> 105277 bytes)
[2026-07-26 03:45:05] rotate done (dir=20M)
agentmemory.err.log
shrank from 5.2MB → 324KB, and hook-latency.jsonl
from 5.1MB → 174KB.
Note
The reason for the two-steptail -n 2000 "$f" > "$f.tmp" && mv "$f.tmp" "$f"
is to prevent a redirect to the same file from blowing away its contents. Never use a direct> "$f"
overwrite.
Here is the full text of ~/Library/LaunchAgents/com.shun.log-rotate.plist
.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.shun.log-rotate</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>~/.claude/scripts/log-rotate.sh</string>
</array>
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>45</integer>
</dict>
</array>
<key>LowPriorityIO</key><true/>
<key>Nice</key><integer>10</integer>
<key>ProcessType</key><string>Background</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/log-rotate.err</string>
</dict>
</plist>
Three things matter here.
| Key | Value | Purpose |
|---|---|---|
LowPriorityIO |
||
| true | Drops I/O to background priority so it doesn't block foreground work | |
Nice |
||
| 10 | Lowers CPU priority (normally 0; larger values mean lower priority) | |
ProcessType |
||
| Background | A declaration to the macOS scheduler, used in its thermal, battery, and memory pressure calculations |
I set the start time to 03:45 to avoid the cluster of other jobs around 03:30. Autopilot runs at 05:00 and daily-brief at 06:00, so log cleanup gets done quietly before them.
Note
launchdskipsruns while the machine is asleep. If it was sleeping, the job won't run until the next matching time. If you want it to run reliably every morning, either enable wake on scheduler withpmset -a wake 1
, or line up your non-sleeping hours with 03:45.
log-rotate.log
retains the size from each morning. Extracting July's progression gives this.
[2026-07-03] rotate done (dir=6.3M)
[2026-07-06] rotate done (dir=9.5M)
[2026-07-07] rotate done (dir=13M)
[2026-07-11] rotate done (dir=17M)
[2026-07-12] truncated agentmemory.err.log → rotate done (dir=13M) ← 初回切詰め
[2026-07-15] rotate done (dir=15M)
[2026-07-19] truncated 2本 → rotate done (dir=8.1M) ← 2回目
[2026-07-25] rotate done (dir=18M)
[2026-07-26] rotate done (dir=20M)
On 07-12, when truncation ran, it dropped from 19M → 13M; on 07-19, from 17M → 8.1M. It keeps accumulating until a truncation target exceeds 5MB, then shrinks all at once the moment it does — a sawtooth pattern. The current 21M reflects SELF_LOG approaching 5MB, so it should shrink again at tomorrow's 03:45.
> "$f"
→ the file ends up emptytail
reads it, leaving zero content. Always use the two-step tmp → mv.StandardErrorPath
, stderr disappearslog-rotate.err
.stat -f%z
is macOS-onlystat -c%s
. This plist is macOS launchd-specific so it's not an issue here, but be careful when taking the script to another environment.find -mtime +30
is based on modification timemaxdepth 1
pulls in files in subdirectories too~/.claude/logs/
is flat right now, but that would cause accidents once I add subdirectories in the future. -maxdepth 1
is mandatory.Nice 10
alone still stalls when I/O gets heavyLowPriorityIO
. Nice is CPU, LowPriorityIO is disk; they're separate axes.~/.claude/logs/
balloons past 21M if left alone (188 files)LowPriorityIO=true
-
Nice=10 -
ProcessType=Background
in launchd makes it run with low CPU and I/O load during the quiet hours after wakeNext time I plan to write about the other reason these logs pile up — digging into the root cause of why agentmemory.err.log keeps hitting 5MB so frequently.
*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*