← All posts

Debugging a Ghost in the Machine: Session Isolation for Claude Code Plugins

I run multiple Claude Code sessions in the same project all the time. One session handles a long-running task via a Ralph Wiggum loop (a self-referential iteration technique), while I open another ses

  • claude-code
  • ai-tooling
  • hooks
  • plugins
  • debugging

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.

Session Isolation for Claude Code Plugins

The bug was subtle but reproducible. Finding it forced me to understand how Claude Code’s hooks actually work.

The hook behavior that mattered

Claude Code has an event-driven hook system — small shell scripts that run when a session starts (SessionStart), tries a tool (PreToolUse), tries to stop (Stop), and so on. Plugins register these scripts. The important detail is that hooks are global: every registered hook fires for every session in the project.

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 is how the Ralph Wiggum loop works. The Stop hook intercepts the exit, reads the loop state, and feeds the prompt back in. Claude sees its previous work in the codebase and iterates. The problem appears when a second session opens.

The collision

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:

EventWhat happened
Session A runs /ralph-loopCreates .claude/ralph-loop.local.md
Session A works on its taskStop hook fires on exit, finds the state file, blocks the exit, feeds the prompt back
Session B opens for unrelated workSame project directory, same plugin hooks registered
Session B finishes its taskStop hook fires, finds Session A’s state file, blocks Session B’s exit
Session B is now in the ralph loopWorking on Session A’s prompt, but with Session B’s context

The Stop hook had no session identity. It asked one question — does the state file exist? — and blocked the exit when the answer was yes. It did not matter that another window had created the file. A single-session test will never expose this isolation bug. Any plugin that writes to disk needs a two-session test; it is the ordinary concurrent-access problem — two processes writing to one path — at the AI-session level.

The fix: one 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.”

There was one catch. The setup script that creates the state file when /ralph-loop runs uses the Bash tool (the command-run feature inside a Claude Code session), not a hook. It does not receive the same JSON input. It still needed the session ID.

Passing the ID across the boundary

Hooks receive JSON with the session ID, transcript path, and current working directory. Bash commands run through the tool do not. Claude Code provides CLAUDE_ENV_FILE: a SessionStart hook can write export statements into it, and later commands in that session read them as environment variables. This is the canonical way to pass session context across the hook and command boundary:

#!/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"

The fallback — a random 12-character ID — covers a session that started before the new SessionStart hook was installed and never received CLAUDE_SESSION_ID. It invents an ID instead of crashing.

The fix is submitted upstream to the ralph-wiggum plugin in the claude-code repo. If a plugin has caused mysterious interference between sessions, shared state is very likely why.

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/null

Try 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.