Last week one of my Claude Code sessions got hijacked. I’d opened it for a quick, unrelated fix; it finished the job — and then, instead of handing control back to me, it picked up a long-running task from a different session in the same project and started working on that instead.
Running several sessions in one project at once is normal for me. One will grind through a long task on a Ralph Wiggum loop — a self-referential technique where the agent feeds its own prompt back to itself and keeps iterating — while I spin up another on the side for a quick fix. Two sessions, one directory, no problem. Until there was.
Hooks are global, but state must be local. Any plugin that keeps its state in one fixed-name file will collide the moment two sessions share a project directory — and Claude Code makes sharing one trivial.

The bug was subtle and entirely reproducible, and chasing it taught me how Claude Code’s hook system actually works under the hood.
How Claude Code hooks work
Claude Code has an event-driven hook system — small shell scripts that fire automatically when something happens in a session: it starts (SessionStart), it tries to run a tool (PreToolUse), it tries to stop (Stop), and so on. Plugins register these scripts, and here’s the detail that bit me: hooks are global. Every registered hook fires for every session in the project, not just the one that installed it.
The Stop hook is the interesting one. When Claude tries to end a conversation, a Stop hook can refuse to let it exit by returning a JSON payload:
{
"decision": "block",
"reason": "Your next prompt goes here",
"systemMessage": "Context injected as a system message"
}
That’s the mechanism behind the Ralph Wiggum loop. The Stop hook intercepts the exit, reads the loop’s state file, and feeds the same prompt back in. Claude sees its own previous work sitting in the codebase and iterates on it. Elegant — until you open a second session.
The bug: shared state, global hooks
The ralph-wiggum plugin kept its loop state in a single file: .claude/ralph-loop.local.md. The Stop hook checked whether that file existed and, if it did, blocked the exit. The problem was obvious once I traced it — though it took me a minute to get there, because each session looked perfectly healthy on its own:
| Event | What happened |
|---|---|
Session A runs /ralph-loop | Creates .claude/ralph-loop.local.md |
| Session A works on its task | Stop hook fires on exit, finds the state file, blocks the exit, feeds the prompt back |
| Session B opens for unrelated work | Same project directory, same plugin hooks registered |
| Session B finishes its task | Stop hook fires, finds Session A’s state file, blocks Session B’s exit |
| Session B is now in the ralph loop | Working on Session A’s prompt, but with Session B’s context |
The Stop hook had no way to know which session it belonged to. It asked one question — does the state file exist? — and if yes, blocked the exit. It didn’t matter that a different session in a different window had created the file.
The fix: one state file per session
Claude Code includes a session_id — a unique ID it assigns to each conversation — in the JSON payload it pipes to every hook via stdin (the standard input stream every Unix program can read). The fix is to use that ID to give each session its own state file:
# In the Stop hook — extract session_id from hook input
HOOK_INPUT=$(cat)
SESSION_ID=$(echo "$HOOK_INPUT" | jq -r '.session_id // empty')
# Only look for THIS session's state file
RALPH_STATE_FILE=".claude/ralph-loop.${SESSION_ID}.local.md"
if [[ ! -f "$RALPH_STATE_FILE" ]]; then
# Not our loop — allow normal exit
exit 0
fi
jq is a command-line tool for pulling fields out of JSON; the // empty says “if session_id is missing, return nothing instead of the literal word null.”
But there’s a catch, and it stopped me cold for a moment. The setup script — the one that actually creates the state file when you run /ralph-loop — runs through the Bash tool (the command-run feature inside a Claude Code session), not as a hook. It doesn’t receive the same JSON input. So how does it learn the session ID?
The bridge: SessionStart + CLAUDE_ENV_FILE
Claude Code has a mechanism called CLAUDE_ENV_FILE. During a SessionStart hook you can write export statements into this file, and they become environment variables — settings every later command in that session can read. That’s the bridge between the hook world and the command world:
#!/bin/bash
# session-start-hook.sh — fires when any session begins
HOOK_INPUT=$(cat)
SESSION_ID=$(echo "$HOOK_INPUT" | jq -r '.session_id // empty')
if [[ -n "$SESSION_ID" ]] && [[ -n "${CLAUDE_ENV_FILE:-}" ]]; then
echo "export CLAUDE_SESSION_ID='$SESSION_ID'" >> "$CLAUDE_ENV_FILE"
fi
Now the setup script can read $CLAUDE_SESSION_ID and create the matching state file:
# In setup-ralph-loop.sh
SESSION_ID="${CLAUDE_SESSION_ID:-$(date +%s%N | md5sum | cut -c1-12)}"
RALPH_STATE_FILE=".claude/ralph-loop.${SESSION_ID}.local.md"
That fallback — a random 12-character ID — covers the edge case I wasn’t sure how to handle at first: a session that started before the new SessionStart hook was installed, and so never had CLAUDE_SESSION_ID stamped into its environment. Rather than crash, it invents an ID and carries on.
Architecture: the three-script pattern
the mechanism — how hook isolation actually works give me the detail
Why hooks share a process namespace. Claude Code plugins are registered at the project level in .claude/settings.json. Every shell hook is a subprocess forked by the same Node.js host process, so there is no per-session sandbox — two concurrent sessions share one hook registry and one filesystem. The root cause of this bug is identical to a classic Unix race condition: two processes writing to the same path, neither aware of the other.
CLAUDE_ENV_FILE is a tmpfile bridge. Claude Code sets CLAUDE_ENV_FILE to a per-session tempfile path before invoking each hook. Writes to that file (echo "export FOO=bar" >> "$CLAUDE_ENV_FILE") are sourced into the environment for every subsequent Bash tool call in that session only — other sessions have different tempfile paths. That’s what makes it a safe inter-boundary channel: same filesystem, different inodes.
jq for extraction, glob for cleanup. The hooks themselves are vanilla bash — no SDK required. The entire session-id plumbing is two jq one-liners on the JSON piped to stdin, plus a glob to reap orphaned state files on SessionStart:
# SessionStart hook — stamp env, reap orphans older than 24h
HOOK_INPUT=$(cat)
SESSION_ID=$(echo "$HOOK_INPUT" | jq -r '.session_id // empty')
[[ -n "$SESSION_ID" && -n "${CLAUDE_ENV_FILE:-}" ]] &&
echo "export CLAUDE_SESSION_ID='$SESSION_ID'" >> "$CLAUDE_ENV_FILE"
# Clean up state files from sessions that exited without cleanup
find .claude -name 'ralph-loop.*.local.md' -mmin +1440 -delete 2>/dev/nullTry it yourself. Open two terminals in any Claude Code project, run a long loop in one, then type exit in the other. Before this fix, the second session blocks. After: it exits cleanly because [[ ! -f ".claude/ralph-loop.${SESSION_ID}.local.md" ]] is true — the wrong session’s state file simply isn’t there.
What I learned
Hooks are global, state must be local. Any plugin that stores state in a fixed-name file will break the moment two sessions run in the same project. If you’re writing a Claude Code plugin, scope your state files by session ID — always.
CLAUDE_ENV_FILE is the bridge between hooks and commands. Hooks receive rich JSON context (session ID, transcript path, current working directory). Bash commands run through the tool don’t. The SessionStart hook plus CLAUDE_ENV_FILE lets you promote hook-only data into environment variables that Bash commands can read. This is the canonical way to pass session context across that boundary.
Test with parallel sessions. Single-session testing will never surface an isolation bug. Any time your plugin writes to the filesystem, ask: what happens if two sessions both run this? It’s the classic concurrent-access problem — two processes writing to the same path — just lifted from the thread level to the AI-session level.
The fix is submitted upstream to the ralph-wiggum plugin in the claude-code repo. If you’ve hit mysterious session interference with a plugin, this is very likely why.