Repository navigation
Description
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
-
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
-
Start Claude Code interactively, then use
/resumeor--continueto resume the session -
In the resumed session, run:
echo $MY_TEST_VAR -
Expected:
hello -
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
- CLAUDE_ENV_FILE not provided to SessionStart hooks #15840 — CLAUDE_ENV_FILE empty string (different bug, may be fixed for project hooks)
- [BUG] SessionStart hook doesn't receive CLAUDE_ENV_FILE when installed by a plugin #11649 — CLAUDE_ENV_FILE not passed to plugin hooks (fixed)
- [BUG] CLAUDE_ENV_FILE not sourced on resumed sessions (--continue) #12418 — CLAUDE_ENV_FILE not sourced on resume (closed as duplicate of [BUG] SessionStart hook doesn't receive CLAUDE_ENV_FILE when installed by a plugin #11649, but this is a separate root cause)
Activity
github-actions commentedon Feb 10, 2026
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
cowwoc commentedon Feb 10, 2026
This is not a duplicate of any of the three linked issues. Here's why:
#15840 — Reports
CLAUDE_ENV_FILEbeing an empty string. OurCLAUDE_ENV_FILEis 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_FILEat 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 whenCLAUDE_ENV_FILEpaths are generated for hooks, but returns the resumed session ID whenybI()reads env files for Bash tool calls. The env files are written tosession-env/STARTUP_ID/but read fromsession-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.
Zrowsdower commentedon Feb 27, 2026
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:/clearor compact--resume/--continue--resume+ async signal onlyThe 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 whereRbI()lives. This alone fixes/clearand 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_FILEfor CLI PATH injection. After isolating the two issues, we implemented a broadcast workaround:This overwrites the PATH export in every
~/.claude/session-env/*/directory on every SessionStart event. Combined with the async signal (to clearybI()'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
RbI()works via{"async": true}stdout, but this requirement is undocumented. Fixes/clearand compact independently of this issue.Zrowsdower commentedon Feb 27, 2026
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}orSCRIPT_DIR(both go stale), read the current install path from Claude Code's own plugin registry:installed_plugins.jsonis 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
/bintoCLAUDE_ENV_FILEis worse than doing nothing.Layer 2: Overwrite (>) not append (>>)
Use
>(overwrite) when writing toCLAUDE_ENV_FILE, not>>(append). On/clearand 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
Two purposes:
"async": truetriggersRbI()(cache invalidation) on hook completion, soybI()re-reads from disk instead of serving cached startup dataadditionalContextinjects 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 pathWhy all four layers are needed
CLAUDE_PLUGIN_ROOTafter plugin update/clear--resume(this issue)ybI()reads staleRbI()never called for sync hooksRemoving 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--resumeat 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 toCLAUDE_ENV_FILE.github-actions commentedon Mar 28, 2026
Closing for now — inactive for too long. Please open a new issue if this is still relevant.
github-actions commentedon Apr 5, 2026
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.