Claude Code Filled /tmp and Froze My Linux Box
Claude Code's scratchpad lives in /tmp, which is RAM on my Omarchy box. 7.1 GB of worktrees froze it. The fix, what it still misses, and a checklist.
In this post
TL;DR: In the Claude Code version I run, each session’s scratchpad lives under
/tmp. On my Omarchy machine/tmpis tmpfs, so every file there is RAM, and one session filled it with 7.1 GB of git worktrees and froze the box.psshowed nothing because tmpfs shows up in thesharedcolumn offree, not in any process. I fixed it with a hook, a CLAUDE.md rule, and a 3G cap on/tmp. The machine hasn’t frozen since./tmpstill filled to the cap several more times, through paths the hook can’t see, and each time one session failed while the box stayed up.
On 2026-09-19 my machine froze. Load average was 21 on a 12-core MacBook Pro. I had several coding-agent sessions going, some of them running npm installs inside git worktrees, and a backend test suite was running at the same time.
One of the sessions had built four git worktrees of a large Node and
TypeScript monorepo inside its own scratchpad under /tmp,
each with a full node_modules. That was 7.1 GB, and all of
it was sitting in RAM.
The machine
An Apple MacBook Pro 14-inch M2 Pro (2023) with 16 GB of RAM
(free shows 15Gi), running Omarchy on Asahi. I wrote up the
install on my M1 in I Put
Omarchy on an M1 MacBook Pro; the M2 Pro went the same route. On
this setup /tmp is tmpfs. At the time the machine also had
0 B of swap.
tmpfs is a filesystem that lives in memory. If you don’t set a size, the kernel limits it to half of physical RAM, per the kernel tmpfs docs. On 16 GB that was about 7.6G. So the limit was higher than the 7.1 GB I had in there, and with no swap there was nowhere for the pages to go.
On my machine, Claude Code’s harness keeps each session’s scratchpad
under /tmp. The path looks like this (the middle part is
the working directory, encoded):
/tmp/claude-<uid>/<encoded-cwd>/<session>/scratchpad
That is fine for logs, diffs and small scripts. It is a bad place for
npm ci, and in my case the agent did it there, because the
scratchpad is the directory it was told is its own.
Why ps found nothing
I went to ps first, sorted by memory, and nothing in it
accounted for a machine at load 21.
tmpfs pages don’t belong to a process. They don’t show in anyone’s
RSS. They show in the
shared column of free. If you see a large
number there and no process to blame, look at tmpfs.
The diagnosis order
This is the order I use now, and each step narrows the next:
free -h # big "shared" column?
df -h -t tmpfs # is /tmp near 100%?
du -xsh /tmp/* # what is in there?
git worktree list | grep tmpThe last line is specific to my case, but worktrees are an easy thing for an agent to create and forget about. Removing the four worktrees took the load average from 21 to 0.4.
The fix, in three layers
I did all three the same day.
1. A hook that blocks the command. Claude Code runs
a PreToolUse hook before a tool call. If the hook exits with code 2, the
call is blocked and the hook’s stderr goes back to Claude as the reason,
per the Claude Code
hooks docs. The command arrives as JSON on stdin, so the script
reads tool_input.command with jq. I covered
the general idea in The Four
Claude Code Hooks I Run on Every Project. This is
~/.claude/hooks/no-heavy-tmp.sh:
#!/usr/bin/env bash
# PreToolUse(Bash): block heavy writes into /tmp.
# tmpfs = RAM.
cmd=$(jq -r '.tool_input.command // empty' 2>/dev/null) \
|| exit 0
[ -n "$cmd" ] || exit 0
heavy='(npm (ci|install|i)\b|pnpm (install|i)\b'
heavy+='|yarn( install)?\b|git worktree add|git clone'
heavy+='|npx expo install)'
if grep -Eq "$heavy" <<<"$cmd" &&
grep -Eq '(^|[^[:alnum:]_./-])/tmp/' <<<"$cmd"; then
msg="Blocked: heavy install/clone into /tmp"
msg+=" (tmpfs = RAM)."
msg+=" Use a disk path: \$HOME/.cache/claude-work or the"
msg+=" repo's .claude/worktrees/."
echo "$msg" >&2
exit 2
fi
exit 0I split a few long lines to fit this page. The behavior is the same.
It blocks a command only when it contains an install, clone or
worktree-add and also a /tmp/ path.
A minimal version of the wiring in
~/.claude/settings.json, with a Bash
matcher:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "~/.claude/hooks/no-heavy-tmp.sh"
}]
}]
}
}My real entry wraps the call in [ -x "$f" ] || exit 0,
so a machine without the script skips the hook instead of erroring.
2. A rule in the global CLAUDE.md. One short rule,
imported into every session: /tmp is RAM, and heavy scratch
work goes to ~/.cache/claude-work/<session> or the
repo’s own .claude/worktrees/. The hook catches the
mistake, and the rule means the agent usually doesn’t make it.
3. A kernel cap on /tmp. A systemd drop-in at
/etc/systemd/system/tmp.mount.d/size.conf holds a
[Mount] section with one Options= line (too
wide for a code block here):
Options=mode=1777,strictatime,nosuid,nodev,size=3G,nr_inodes=1m
The default was 50% of RAM, about 7.6G. With the 3G cap, the worst
case is one session getting ENOSPC (no space left on
device). The machine doesn’t thrash. It works even when the agent
ignores the rule and the regex misses.
What the hook still misses
The hook has two gaps I knew about when I wrote it. If a call does
cd /tmp/x and a later call runs npm ci, the
second command has no /tmp in it, so the hook never sees
it. And it has a false positive: a heredoc whose text merely mentions
both an install and /tmp/ gets blocked. I hit that one
while writing the notes for this fix. If you need to write a file like
that, use a file-write tool instead of a heredoc.
Then a gap I didn’t know about showed up. On 2026-09-30, again on
2026-10-15 and again on 2026-10-20, the 3G /tmp filled all
the way to ENOSPC. The cause was Node’s
compile cache and the cache from tsx (a TypeScript runner). On my
box both ended up under /tmp (they follow
$TMPDIR), and the agents ran no install, clone or worktree
command, so there was nothing for the hook to match. The cap did its job
here: one session failed with ENOSPC and the machine stayed
up. The remedy was to point NODE_COMPILE_CACHE and
TMPDIR at disk for build agents.
Two other things also showed up:
- 2026-10-04: orphaned headless browsers (puppeteer) were a third
class of
/tmpfiller. - 2026-10-08: three OOM kills when several build-heavy agents ran at
once in one repo. The tmpfs cap limits
/tmp. It does nothing about how much memory concurrent agents use as normal processes. Parallel build agents need their own limit. I wrote about isolating parallel agents’ ports, databases and asset watchers in Isolating Parallel Claude Code Agents in Phoenix; memory limits are the piece that post doesn’t cover.
So the cap did its job. /tmp kept filling, and the
machine never froze again.
What changed after the reinstall
On 2026-10-31 I reinstalled both Macs onto the Omarchy dev build.
That brought a newer kernel, and Claude Code has also updated many times
since September (it is on 2.1.317 now). It also gave the M2 Pro zram
swap. The config is
/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf,
owned by the package omarchy-settings-dev, and it sets:
zram-size = ram
compression-algorithm = zstd
swap-priority = 100swapon --show now lists /dev/zram0 at 15G.
Before the reinstall this machine had 0 B of swap.
There’s been no freeze since, and I can’t tell you which change did it. I changed the hook, the rule, the cap, the swap, the kernel and Claude Code itself between September and now, and didn’t test them one at a time.
The hook wasn’t even running
While writing this post I went to copy the hook’s wiring out of
settings.json and it wasn’t there. The script was still in
~/.claude/hooks/, but no PreToolUse entry
pointed at it. My dotfiles merge settings.json from a base
file, and that merge replaces each hook array wholesale. The
/tmp hook was never in the base file, so a merge dropped
it. I don’t know which run did it, so I don’t know how long it was
off.
So whatever kept things quiet since the reinstall, it wasn’t the
hook. It was the cap, the zram swap, the updates, or luck, and a few
days isn’t enough to tell which. The hook is now tracked in my dotfiles
and registered in the base file, and I tested that it blocks again. The
first thing it blocked was the script I used to edit this post, because
the post mentions an install and a /tmp path together.
That’s the heredoc false positive from earlier. I wrote up what that
merge gets wrong in Claude Code on Multiple
Computers: What to Sync.
Now I check the wiring too, not just the file:
jq '.hooks.PreToolUse' ~/.claude/settings.json.
A checklist for your own box
- Run
findmnt /tmp. If the type istmpfs, everything written there is memory. - Run
swapon --show. No output means no swap, and a full/tmphas nowhere to spill. - Set a size on
/tmp. Use atmp.mount.ddrop-in like the one above, and pick a number you can live with if a single session fills it. - Add a PreToolUse hook for installs, clones and worktree-adds under
/tmp/, or write your own. Keep it small, and know it will misscdfollowed by an install. - Tell the agent in CLAUDE.md where heavy scratch work goes.
- Point
TMPDIRand tool caches (NODE_COMPILE_CACHE, the tsx cache) at disk for agents that build. - Limit how many build-heavy agents run in the same repo at once.
- Next time the box crawls and
psis clean, runfree -hand read thesharedcolumn before you do anything else.
If you run coding agents on Linux and want a second pair of eyes on how they’re set up, that’s part of what I do on the consulting page.