Skip to content

CLAUDE_ENV_FILE: session ID mismatch on resume causes env files to be written to wrong directory #24775

Closed as not planned

Description

@cowwoc

Description

When resuming a session (--continue, /resume, or context compaction), CLAUDE_ENV_FILE points to a startup session's directory, but the env file loading function (ybI() in the compiled binary) reads from the resumed session's directory. This means environment variables written by SessionStart hooks are never found.

Steps to Reproduce

  1. Register a project SessionStart hook that writes to CLAUDE_ENV_FILE:

    #!/bin/bash
    INPUT=$(cat)
    if [ -n "$CLAUDE_ENV_FILE" ]; then
      echo 'export MY_TEST_VAR="hello"' >> "$CLAUDE_ENV_FILE"
    fi
    exit 0
  2. Start Claude Code interactively, then use /resume or --continue to resume the session

  3. In the resumed session, run:

    echo $MY_TEST_VAR
    
  4. Expected: hello

  5. Actual: Variable is unset

Root Cause

Verified by analyzing the compiled binary's JavaScript source (strings /path/to/claude):

At hook execution time (SessionStart), R$() returns the startup session ID (a transient session created before the resume completes). The CLAUDE_ENV_FILE path is generated as:

~/.config/claude/session-env/STARTUP_SESSION_ID/sessionstart-hook-N.sh

At Bash tool execution time, R$() returns the resumed session ID. The env loader (ybI()) reads from:

~/.config/claude/session-env/RESUMED_SESSION_ID/

Since these are different directories, the env files are never found.

Evidence

Debug hooks logging CLAUDE_ENV_FILE and stdin JSON during SessionStart:

# Startup SessionStart (source: "startup")
CLAUDE_ENV_FILE=~/.config/claude/session-env/21a1c086-.../sessionstart-hook-0.sh
stdin: {"session_id":"21a1c086-...","source":"startup"}

# Resume SessionStart (source: "resume")  
CLAUDE_ENV_FILE=~/.config/claude/session-env/21a1c086-.../sessionstart-hook-0.sh  ← SAME startup dir
stdin: {"session_id":"8b55bdb2-...","source":"resume"}  ← DIFFERENT session ID

Then in Bash tool calls, ybI() looks in session-env/8b55bdb2-.../ which is empty.

Confirmed working on fresh sessions: Pre-seeding session-env/SESSION_ID/sessionstart-hook-0.sh and running claude --session-id SESSION_ID correctly sources the env file. The loading mechanism works — only the session ID mismatch on resume is broken.

Confirmed broken on resume: Writing to the startup session's directory (where hooks actually write) and verifying the resumed session can't find those files.

Additional Note

There is also a caching issue: ybI() caches its result after the first call. If the first call happens before env files exist, it caches null and never re-checks. The cache is only invalidated on compaction/SessionStart events via RbI(). This is a secondary concern but compounds the primary session ID mismatch.

Environment

  • Claude Code 2.1.38
  • Linux (WSL2)
  • Hooks registered in .claude/settings.json (project level)

Related Issues

Activity

  1. github-actions commented on Feb 10, 2026

    @github-actions

    Found 3 possible duplicate issues:

    1. [BUG] CLAUDE_ENV_FILE not sourced on resumed sessions (--continue) #12418
    2. [BUG] CLAUDE_ENV_FILE not sourced after /clear creates new session #14433
    3. CLAUDE_ENV_FILE not provided to SessionStart hooks #15840

    This issue will be automatically closed as a duplicate in 3 days.

    • If your issue is a duplicate, please close it and 👍 the existing issue instead
    • To prevent auto-closure, add a comment or 👎 this comment

    🤖 Generated with Claude Code

  2. cowwoc commented on Feb 10, 2026

    @cowwoc
    Author

    This is not a duplicate of any of the three linked issues. Here's why:

    #15840 — Reports CLAUDE_ENV_FILE being an empty string. Our CLAUDE_ENV_FILE is correctly set and non-empty. Different bug.

    #12418 — Describes the same symptom (env vars not available on resume) but was closed as a duplicate of #11649, which was about plugin hooks not receiving CLAUDE_ENV_FILE at all. Our issue identifies a different root cause: the session ID mismatch between hook execution time and env file loading time. #12418 never identified this root cause, and closing it as a duplicate of #11649 didn't fix the resume case.

    #14433 — Reports the same underlying mechanism but for /clear, not resume. Related but a different trigger with potentially different fix paths.

    What makes this issue distinct:

    This report identifies the specific root cause with evidence from the compiled binary source: R$() returns the startup session ID when CLAUDE_ENV_FILE paths are generated for hooks, but returns the resumed session ID when ybI() reads env files for Bash tool calls. The env files are written to session-env/STARTUP_ID/ but read from session-env/RESUMED_ID/. The evidence section in the issue body shows the exact session IDs and directory paths demonstrating the mismatch.

    None of the linked issues identify this root cause.

  3. Zrowsdower commented on Feb 27, 2026

    @Zrowsdower

    Confirming this bug independently on macOS (arm64), Claude Code 2.1.62. @cowwoc's root cause analysis is correct — the session ID mismatch is the core issue.

    Additional finding: the cache invalidation is independent

    @cowwoc noted the caching in ybI() as a "secondary concern." We've confirmed it's actually an independent issue that affects different scenarios:

    Scenario Directory mismatch? Cache stale? Result
    Fresh start No No Works
    /clear or compact No Yes (without async signal) Stale
    --resume / --continue Yes Yes (without async signal) Stale
    --resume + async signal only Yes No Still stale

    The cache invalidation function (RbI()) works correctly — but it's only called in the async hook completion path. Sync hooks (the default shown in docs) never trigger it.

    The fix for the cache issue: Output {"async": true} to stdout from your SessionStart hook. This triggers the async completion path where RbI() lives. This alone fixes /clear and compact — but does NOT fix resume, because even after cache invalidation, ybI() re-reads from the resumed session's directory (which hooks never wrote to).

    Working workaround for resume

    We ship a plugin that depends on CLAUDE_ENV_FILE for CLI PATH injection. After isolating the two issues, we implemented a broadcast workaround:

    # After writing to our own CLAUDE_ENV_FILE, broadcast to ALL session-env dirs.
    # On resume, R$() returns a different session ID — ybI() reads from the original.
    # Overwriting all dirs ensures the original gets our PATH.
    EXPORT_LINE='export PATH="'"$PLUGIN_BIN"':$PATH"'
    for dir in "$HOME/.claude/session-env"/*/; do
      [ -d "$dir" ] || continue
      for f in "$dir"sessionstart-hook-*.sh; do
        [ -f "$f" ] || continue
        if grep -q "TOOL_NAME_HERE" "$f" 2>/dev/null; then
          printf '%s\n' "$EXPORT_LINE" > "$f"
        elif ! grep -q "$PLUGIN_BIN" "$f" 2>/dev/null; then
          printf '%s\n' "$EXPORT_LINE" >> "$f"
        fi
      done
    done

    This overwrites the PATH export in every ~/.claude/session-env/*/ directory on every SessionStart event. Combined with the async signal (to clear ybI()'s cache), resume reads correct data from the original directory.

    **This works for us and our setup, i'm not saying it won't break your setup if you utilize path injection for anything else.

    Summary

    • Directory mismatch (this issue): Real bug, no native fix. Broadcast workaround above.
    • Cache invalidation: Design gap — RbI() works via {"async": true} stdout, but this requirement is undocumented. Fixes /clear and compact independently of this issue.
    • These are independent — fixing either one alone doesn't fix all scenarios. Both must be addressed.
  4. Zrowsdower commented on Feb 27, 2026

    @Zrowsdower

    Confirming this bug independently on macOS (arm64), Claude Code 2.1.62. @cowwoc's root cause analysis is correct — the session ID mismatch is the core issue.

    Additional finding: the cache invalidation is independent

    @cowwoc noted the caching in ybI() as a "secondary concern." We've confirmed it's actually an independent issue that affects different scenarios:

    Scenario Directory mismatch? Cache stale? Result
    Fresh start No No Works
    /clear or compact No Yes (without async signal) Stale
    --resume / --continue Yes Yes (without async signal) Stale
    --resume + async signal only Yes No Still stale
    The cache invalidation function (RbI()) works correctly — but it's only called in the async hook completion path. Sync hooks (the default shown in docs) never trigger it.

    The fix for the cache issue: Output {"async": true} to stdout from your SessionStart hook. This triggers the async completion path where RbI() lives. This alone fixes /clear and compact — but does NOT fix resume, because even after cache invalidation, ybI() re-reads from the resumed session's directory (which hooks never wrote to).

    Working workaround for resume

    We ship a plugin that depends on CLAUDE_ENV_FILE for CLI PATH injection. After isolating the two issues, we implemented a broadcast workaround:

    After writing to our own CLAUDE_ENV_FILE, broadcast to ALL session-env dirs.

    On resume, R$() returns a different session ID — ybI() reads from the original.

    Overwriting all dirs ensures the original gets our PATH.

    EXPORT_LINE='export PATH="'"$PLUGIN_BIN"':$PATH"'
    for dir in "$HOME/.claude/session-env"//; do
    [ -d "$dir" ] || continue
    for f in "$dir"sessionstart-hook-
    .sh; do
    [ -f "$f" ] || continue
    if grep -q "TOOL_NAME_HERE" "$f" 2>/dev/null; then
    printf '%s\n' "$EXPORT_LINE" > "$f"
    elif ! grep -q "$PLUGIN_BIN" "$f" 2>/dev/null; then
    printf '%s\n' "$EXPORT_LINE" >> "$f"
    fi
    done
    done
    This overwrites the PATH export in every ~/.claude/session-env/*/ directory on every SessionStart event. Combined with the async signal (to clear ybI()'s cache), resume reads correct data from the original directory.

    **This works for us and our setup, i'm not saying it won't break your setup if you utilize path injection for anything else.

    Summary

    • Directory mismatch (this issue): Real bug, no native fix. Broadcast workaround above.
    • Cache invalidation: Design gap — RbI() works via {"async": true} stdout, but this requirement is undocumented. Fixes /clear and compact independently of this issue.
    • These are independent — fixing either one alone doesn't fix all scenarios. Both must be addressed.

    Complete workaround architecture (for plugin authors)

    The broadcast snippet above is one layer of a four-layer workaround we developed for our plugin. Sharing the full picture since other plugin authors hitting this will need more than just the broadcast.

    The core problem for plugins

    Plugins that need a CLI on PATH face a compound issue: you need to know your current install path at hook execution time, but ${CLAUDE_PLUGIN_ROOT} (which hooks.json expands) goes stale when a plugin updates — Claude Code caches the old version's path at session start. On --resume, you're running a new version's hook but the session remembers the old version's paths.

    Layer 1: CPR (Current Plugin Root) resolver

    Instead of relying on ${CLAUDE_PLUGIN_ROOT} or SCRIPT_DIR (both go stale), read the current install path from Claude Code's own plugin registry:

    PLUGIN_KEY="your-plugin@your-marketplace"
    INSTALLED_PLUGINS="$HOME/.claude/plugins/installed_plugins.json"
    
    CURRENT_ROOT=""
    if [ -f "$INSTALLED_PLUGINS" ]; then
      CURRENT_ROOT=$(node -e "
        const data = JSON.parse(require('fs').readFileSync('$INSTALLED_PLUGINS','utf8'));
        const entries = data.plugins?.['$PLUGIN_KEY'] || [];
        if (entries.length > 0) console.log(entries[0].installPath);
      " 2>/dev/null || true)
    fi

    installed_plugins.json is updated by Claude Code on plugin install/update, so it always reflects the current version's path. This is plugin-specific — non-plugin tooling would need its own version sourcing (e.g., which, a known install path, or a version manager).

    Guard: If the CPR resolver fails (file missing, plugin not found), skip all PATH writes. Writing garbage like /bin to CLAUDE_ENV_FILE is worse than doing nothing.

    Layer 2: Overwrite (>) not append (>>)

    Use > (overwrite) when writing to CLAUDE_ENV_FILE, not >> (append). On /clear and compact, SessionStart fires again. Append accumulates stale entries across events; overwrite ensures only the current (CPR-resolved) path is present.

    Layer 3: Session-env broadcast

    This is the workaround for the directory mismatch described in the parent comment. After writing to your own CLAUDE_ENV_FILE, broadcast the same export to ALL ~/.claude/session-env/*/ directories. On --resume, ybI() reads from the original session's directory — the broadcast ensures it finds correct data there.

    Only overwrite files you own (grep for your plugin identifier to avoid clobbering other plugins' env files).

    Layer 4: Async signal + additionalContext

    cat <<EOF
    {"async": true, "hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "your-plugin root: ${CURRENT_ROOT}"}}
    EOF

    Two purposes:

    • "async": true triggers RbI() (cache invalidation) on hook completion, so ybI() re-reads from disk instead of serving cached startup data
    • additionalContext injects the current path into the model's system prompt as a backup channel — even if all env mechanisms fail, the model can see the correct path

    Why all four layers are needed

    Layer What it fixes Without it
    CPR resolver Stale CLAUDE_PLUGIN_ROOT after plugin update PATH points to old version's binaries
    Overwrite Accumulated stale entries from repeated events PATH grows with dead entries on every /clear
    Broadcast Directory mismatch on --resume (this issue) Correct data written to wrong dir, ybI() reads stale
    Async signal RbI() never called for sync hooks In-memory cache serves startup data forever

    Removing any single layer re-exposes a different failure mode. We verified this empirically across versions 1.6.0 through 1.6.9 of our plugin, adding one layer at a time and testing /clear, compact, and --resume at each step.

    Scope

    This workaround is specific to plugins that can read installed_plugins.json. Non-plugin tools using SessionStart hooks for PATH injection would need their own version sourcing for Layer 1, but Layers 2-4 apply universally to anyone writing to CLAUDE_ENV_FILE.

  5. github-actions commented on Mar 28, 2026

    @github-actions

    Closing for now — inactive for too long. Please open a new issue if this is still relevant.

  6. github-actions commented on Apr 5, 2026

    @github-actions

    This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.

  7. locked as resolved and limited conversation to collaborators on Apr 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions