Omarchy

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
  1. The machine
  2. Why ps found nothing
  3. The diagnosis order
  4. The fix, in three layers
  5. What the hook still misses
  6. What changed after the reinstall
  7. The hook wasn’t even running
  8. A checklist for your own box

TL;DR: In the Claude Code version I run, each session’s scratchpad lives under /tmp. On my Omarchy machine /tmp is tmpfs, so every file there is RAM, and one session filled it with 7.1 GB of git worktrees and froze the box. ps showed nothing because tmpfs shows up in the shared column of free, 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. /tmp still 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 tmp

The 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 0

I 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 /tmp filler.
  • 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 = 100

swapon --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

  1. Run findmnt /tmp. If the type is tmpfs, everything written there is memory.
  2. Run swapon --show. No output means no swap, and a full /tmp has nowhere to spill.
  3. Set a size on /tmp. Use a tmp.mount.d drop-in like the one above, and pick a number you can live with if a single session fills it.
  4. Add a PreToolUse hook for installs, clones and worktree-adds under /tmp/, or write your own. Keep it small, and know it will miss cd followed by an install.
  5. Tell the agent in CLAUDE.md where heavy scratch work goes.
  6. Point TMPDIR and tool caches (NODE_COMPILE_CACHE, the tsx cache) at disk for agents that build.
  7. Limit how many build-heavy agents run in the same repo at once.
  8. Next time the box crawls and ps is clean, run free -h and read the shared column 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.