[Windows] Bash tool permanently wedged: hook env (CLAUDE_ENV_FILE) grows on every compact, torn export line fails every command with "line 182: e: command not found" (exit 127)
Summary
On Windows (VSCode extension), the Bash tool inlines the accumulated SessionStart-hook env file (CLAUDE_ENV_FILE) into every command's bash -c preamble without any validation, deduplication, or truncation. In long sessions this block grows unboundedly (hooks re-run and re-append on every compact), and one torn/corrupted export line — produced while two hook processes appended concurrently — permanently wedged the session: every subsequent Bash call (even echo ok) failed with:
/usr/bin/bash: line 182: e: command not found
Exit code 127
e / exp are fragments of the word export. The poisoned env script is cached per session, so the session never recovers; resuming the conversation reloads the corrupted content (error moved to line 190 after a full VSCode restart + resume). Only a brand-new conversation is clean.
Environment
- Claude Code VSCode extension
2.1.211(native binary), Windows 10 Pro 10.0.19045, Git Bash (/usr/bin/bashMSYS2) - SessionStart hooks writing env via
CLAUDE_ENV_FILE(matcherstartup|resume|clear|compact): one user hook writing ~33exportlines with separateappendFileSynccalls, plus a plugin hook (openai-codex plugin) writing 2–3 more lines to the same mechanism
Evidence (from Claude VSCode.log, window 20260716T040938)
The env script size logged by the loader grows in a staircase over a ~2h session — one extra copy of the full export block appended per compact/hook event:
11:10:14 Session environment script ready (1508 chars total)
11:36:40 ... (3071 chars total)
12:15:04 ... (4526 chars total)
12:51:58 ... (6197 chars total) ← then alternates 6197/6089 (two competing hook-file states)
13:03:52 ... (9215 chars total) ← first failing spawn, exit 127, "line 182: e: command not found"
At the failing spawn:
13:03:51.152 File C:\Users\Leona\.claude.json written atomically
13:03:52.731 Hook output does not start with {, treating as plain text
13:03:52.747 Hook output does not start with {, treating as plain text ← two hook processes running
13:03:52.770 [Stall] tool_dispatch_start tool=Bash ...
13:03:52.806 Session environment loaded from 2 hook file(s)
13:03:52.806 Session environment script ready (9215 chars total) ← +3KB jump, torn content
13:03:53.034 [Stall] tool_dispatch_end tool=Bash outcome=error
9215 chars ≈ 190 lines — matching the reported failure lines 182/190. A healthy fresh session's preamble is ~35 lines (verified by dumping /proc/$$/cmdline from inside the Bash tool).
Notes:
- The last successful Bash call was 20 seconds earlier (13:03:32). No user config changed in between.
- Subagents spawned from the wedged session inherited the poisoned env and failed identically.
bash -lcinvoked directly viaspawnSyncon the same machine worked perfectly throughout — the corruption is entirely inside the constructed preamble.
Failure-shape reproduction
A 180-line export block containing one region with an unbalanced quote plus a stranded fragment reproduces the exact user-visible shape — single error, nothing else executes, exit 127:
$ bash repro.sh # 175 lines of export F<i>="x", then:
# export BAD="abc
# export MID="swallowed
# e "
# eval 'echo SHOULD_NOT_PRINT'
# "
repro.sh: line 180: e: command not found
$ echo $?
127
Root cause (three compounding defects)
- Unbounded growth: SessionStart hooks re-run on
compact/resumeand append to the session env; nothing truncates or dedupes by key, so the preamble grows ~1.5KB per compact. - No write/read atomicity: multiple hook processes append to the same mechanism with many small
appendFileSynccalls while the loader reads — a torn line is possible (and observed:e/exp= tornexport). - No validation before inlining: the loader inlines the script into
bash -cverbatim and caches it per session. One bad line (unbalanced quote) silently swallows the rest of the preamble including the user command, and the cache makes it permanent for the session; resume reloads it.
Suggested fixes (any one of these would prevent the wedge)
- Validate each env line (
export KEY="value"shape, balanced quoting) before inlining; drop/log malformed lines instead of poisoning every command. - Deduplicate by key when rebuilding the session env script (also fixes unbounded preamble growth on compact).
bash -nthe assembled preamble once after rebuild; on failure, rebuild from last-known-good instead of caching the poisoned script.
Workarounds we applied (user side)
- Hook now skips env writes when
source === "compact"and batches all exports into a single atomic append — no recurrence since. - Recovery when wedged: start a new conversation (env script cache is per-session). VSCode restart + resume does not help.
Related
- #27987 (CLAUDE_ENV_FILE not sourced on Windows), #24775 (env file session-dir mismatch on resume; loader caching), #15840 (CLAUDE_ENV_FILE empty) — adjacent env-file mechanism issues, none cover this corruption/wedge.
3 Comments
Hit this on the CLI (Windows 11, Git Bash MSYS2), plus a couple of adjacent symptoms, and traced the root cause to the
openai-codexplugin.Diagnostic ask for others in this thread and the related ENAMETOOLONG/EOF reports (#83243, #92543, and the various
spawn ENAMETOOLONGissues): check whether you have theopenai-codexplugin enabled (~/.claude/settings.json→enabledPlugins). Its SessionStart hook is a concrete, common trigger, and several of those reports name no writer.The plugin's SessionStart hook appends 3 exports (
CODEX_COMPANION_SESSION_ID,CODEX_COMPANION_TRANSCRIPT_PATH,CLAUDE_PLUGIN_DATA) on every fire, never deduped, matcher unset so it runs on startup/resume/clear/compact.Two additions to the taxonomy here:
export. With this single writer and no concurrency I still reached a full wedge purely from duplication. On disk: session-env files at e.g. 1248 lines / 130,093 bytes with only 3 unique lines. So dedupe-by-key fixes this class on its own.~8KB+: preamble truncated mid-string inside a single-quoted export, givingunexpected EOF while looking for matching 'at a fixed line (echo hiincluded). This is #92543.~32KB+: command line exceeds the WindowsCreateProcesslimit, Node fails before bash runs, givingCommand failed: ENAMETOOLONG: name too long, uv_spawn.One file, three shapes: exit-127 (torn line), EOF-quote (truncation), ENAMETOOLONG (spawn refused).
Method: confirmed the payload by temporarily pointing
CLAUDE_CODE_GIT_BASH_PATHat a shim that logsGetCommandLineW()verbatim then forwards to real bash. The-cwas dominated by dozens of identicalCODEX_COMPANION_*exports.How I fixed it:
CLAUDE_ENV_FILE).The plugin fix stops one trigger, but the loader hardening you proposed (dedupe by key on rebuild, and
bash -nbefore caching) is the general fix, since any plugin or user hook can cause it.Another reporter, still live: 2.1.269, native install (not the VSCode extension), Windows 11, Git Bash — about two months after the original report on 2.1.211. Trigger here is a SessionStart hook that exports ~90 variables, one append per line, no locking.
Nothing is torn on this machine. The unbounded growth in this issue drives the assembled command line into the separate truncation bug reported in #93618 and #85111, and the result is easily misread as a quoting fault.
Measured live:
shopt/unaliaspreamble and the trailingpwd -P >| <tmpfile>brings thebash -cstring to roughly 8119 bytes before the user's command is appended.So
echo probestill fits and succeeds, while anything slightly longer overflows and the string is cut. The cut lands in different places and presents as different errors from one cause. Walking a single command up by length in one unchanged session:Both appeared minutes apart. The line number tracks the size of the env block, which is why it drifts — 77 to 109 across my transcripts.
This also explains why post-mortem inspection finds nothing. The on-disk files are complete and quote-balanced; every one passes
bash -n. The corruption happens at exec time, when the over-long argument is cut.Dedup is absent, and that is what pushes the block over the line. In the live block, three variables written by one hook appear 5 times each, and six more appear twice each.
Two structural notes not yet in the thread:
~/.claude/session-env/<session-id>/<event>-hook-<N>.sh, one numbered file per hook. A torn line therefore cannot come from two different hooks colliding on one file.sessionstart-hook-*files are never cleared. The engine clearsfilechanged-hook-*andcwdchanged-hook-*, but not these.To dump the real assembled preamble from inside the Bash tool on Git Bash:
Follow-up to my comment above (the ~56-bytes-of-headroom measurement and the two error shapes it produced). The failures have stopped here. The fix was entirely on the hook side, so here is what it took, for anyone hitting this now.
The trigger was a SessionStart hook that appended one
export NAME=valueline per exported variable, on every pass. Sincesessionstart-hook-*files are never cleared, the block grew every pass -- roughly 90 more lines each time.The hook now buffers the pass's exports in memory and writes the whole block once, at the end of the pass, with these properties:
export NAME=and survivesshlex.split. The shape test alone is not enough:export X='oopsmatches the pattern and would otherwise be carried forward for the rest of the session, which is the exact failure mode. Parsing also self-heals a block already damaged by an earlier partial write.finallyaround the whole pass, so a phase that raises does not cost the session the exports earned before it.Measured after the change: three consecutive passes produced a byte-identical block -- 39 exports, 3567 bytes, 0 duplicate names, parsing clean under
bash -n.One recovery note. Deduplicating the already-poisoned live block on a wedged session reclaimed 1484 bytes (7739 -> 6255), and commands that had been failing started working in that same session, with no restart. That does not contradict the caching finding in this issue; the block simply shrank enough to be picked up.
A detail for other hook authors:
shlex.quoteleaves a metacharacter-free value unquoted, which is correct and also smaller. Counting quote characters is not a validity test --shlex.quoteemits the'"'"'idiom, so a correct line can contain an odd number of single quotes. Parse the line instead.A hook can only bound its own block, and several hooks share the preamble, so the engine-side asks in this issue still stand: validate the assembled preamble before caching it, dedupe when rebuilding it, and clear
sessionstart-hook-*files the wayfilechanged-*andcwdchanged-*ones are cleared.