Posted on Jul 28 • Originally published at dev.to
Log files never announce themselves. They just sit there growing until something breaks. This is part of my “Claude Code environment” series — the previous post, “Trimming agent logs“, touched on log bloat in general. This time it’s the concrete implementation: a tail-truncation script that runs every night at 3:45 via launchd.
Left alone, ~/.claude/logs/ swells quietly. Recently agentmemory.err.log hit 5.3MB in a single week, and the week before that hook-latency.jsonl and vault-auto-ingest.log both blew past 5MB at the same time, pushing the whole directory to 20M. Because monitoring scripts hold onto files with tail -f, the logrotate approach of creating a new file is off the table. That’s exactly why I picked a method that keeps the file path fixed and only shaves the contents.
The problem: logrotate changes the path
logrotate‘s default behavior is “rename the old file to .1 and create a new file.” tail -f and inotifywait, which hold the file descriptor via the inode, keep following it — but scripts that read by path (Claude Code’s status line, cron, autopilot) never look at the new file, so the log appears empty.
Rewriting the path settings on the script side after every rotation isn’t something you can sustain operationally. So I went with “fixed path, keep only the tail.”
The fix: keep the tail with tail, overwrite with mv
These three lines are the heart of ~/.claude/scripts/log-rotate.sh.
MAX_BYTES=$((5 * 1024 * 1024)) # 5MB超えたら切詰め対象
KEEP_LINES=2000
tail -n "$KEEP_LINES" "$f" > "$f.tmp" && mv "$f.tmp" "$f"
Enter fullscreen mode Exit fullscreen mode
- Measure the byte count with
statand target only files over 5MB - Write the last 2000 lines out to a temporary file with
tail -n 2000 - Overwrite the original file with the temp file via
mv→ the inode doesn’t change, and neither does the path
The reason it’s > "$f.tmp" && mv rather than redirecting straight into > "$f" is to prevent the file from ending up empty if the process crashes mid-write.
Note
logrotate -c‘scopytruncateoption can also “copy, then truncate the original,” but if a session writes heavily between the copy and the truncation, you lose up to a few seconds’ worth of log lines.tail + mvhas no such window, which makes it the safer option here.
The full script
#!/bin/bash
# log-rotate.sh — ~/.claude/logs の肥大防止(毎日03:45 launchd)
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"
# 1) 肥大ログの切詰め(.log / .err / .out / .jsonl)
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
# 2) 日付付き成果物・マーカー・バックアップの30日超削除
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
# 3) 自分自身のログも肥大防止
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"
Enter fullscreen mode Exit fullscreen mode
-maxdepth 1 keeps it from descending into subdirectories, so only files directly under ~/.claude/logs/ are targeted. Subdirectories sometimes hold project data for other purposes, so this is insurance against accidental deletion.
Running quietly at night with launchd
Here’s the relevant part of ~/Library/LaunchAgents/com.shun.log-rotate.plist.
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>45</integer>
</dict>
</array>
Enter fullscreen mode Exit fullscreen mode
Three settings together create the “quiet nighttime run.”
Key Value EffectLowPriorityIO
true
Lowers disk I/O priority so it doesn’t cut in on other processes
Nice
10
Lowers CPU priority (0 is the default; higher positive values mean lower priority)
ProcessType
Background
macOS manages it under the background quality-of-service class
StartCalendarInterval is set to 3:45 because autopilot starts at 5:00, and the intent is to finish the rollup just before that. Jobs scheduled while the Mac is asleep get skipped, so if the lid is closed overnight it won’t run that day. I’ve accepted that trade-off.
Note
You can set up a wake schedule withpmset -g sched, but waking the machine every morning is bad for the battery even while charging. I chose to tolerate skips during sleep and protect the battery instead.
Deleting dated artifacts older than 30 days
Alongside truncation, the job deletes dated files matching -mtime +30.
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
Enter fullscreen mode Exit fullscreen mode
daily-brief-2*.md and project-health-2*.md are artifacts that Claude Code’s automated tasks emit daily. Even at a few KB to a few dozen KB per file, stacking them up every day adds up to several GB a year. Once they’re past 30 days, they’ve served their purpose and get deleted. .*-done-* and .vault-ingest-* are checkpoint files from cron jobs, and those also pile up into the hundreds if left alone.
Actual truncation records
Here are recent results from ~/.claude/logs/log-rotate.log.
[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-19 03:45:05] rotate done (dir=8.1M)
[2026-07-27 03:45:05] truncated agentmemory.err.log (5590723 bytes -> 297105 bytes)
[2026-07-27 03:45:06] rotate done (dir= 16M)
Enter fullscreen mode Exit fullscreen mode
hook-latency.jsonl went from 5.1MB to 174KB, and vault-auto-ingest.log from 5.0MB to 103KB. Those two files alone cut that day’s directory in half, from 17M to 8.1M. In the most recent run on 2026-07-27, agentmemory.err.log had reached 5.3MB and was brought down to 290KB.
Pitfalls I hit
-
Crashed mid-write with a direct
> "$f"redirect → switched tomvvia atmpfile so it’s atomic -
findrecursed under.claude/and nearly deleted project data → limited it to the top level with-maxdepth 1 -
launchd’s minimal PATH meant the script couldn’t find
stat→ spelled out/opt/homebrew/binin the plist’sEnvironmentVariables -
Jobs got skipped while the Mac slept, leaving logs untouched for days → accepted the skips and kept a one-command manual
launchctl kickstartready for when the machine is awake -
Realized my own
log-rotate.logbloats too → added self-truncation at the end of the script (already built in as the third section of the script above)
Summary
-
~/.claude/logs/accumulates.log/.err/.out/.jsonlfiles and passes 20M if left alone -
Instead of logrotate creating a new file, overwrite with
tail -n 2000 > tmp && mv tmp $f→ the file path never changes, so monitoring scripts don’t break - launchd’s
LowPriorityIO + Nice=10 + ProcessType=Backgroundmakes it run quietly at 3:45 AM - The same job cleans up dated artifacts older than 30 days —
daily-brief-*.mdand friends — withfind -mtime +30 -delete
Next time: how I reuse the session history accumulated in these logs as long-term memory — leading into turning conversation logs into long-term memory.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
답글 남기기