[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)

Status Open
Reported on v2.1.211
Maintainer reply None cached
Activity 3 comments · opened Jul 16, 2026

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/bash MSYS2)
  • SessionStart hooks writing env via CLAUDE_ENV_FILE (matcher startup|resume|clear|compact): one user hook writing ~33 export lines with separate appendFileSync calls, 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 -lc invoked directly via spawnSync on 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)

  1. Unbounded growth: SessionStart hooks re-run on compact/resume and append to the session env; nothing truncates or dedupes by key, so the preamble grows ~1.5KB per compact.
  2. No write/read atomicity: multiple hook processes append to the same mechanism with many small appendFileSync calls while the loader reads — a torn line is possible (and observed: e/exp = torn export).
  3. No validation before inlining: the loader inlines the script into bash -c verbatim 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 -n the 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.

View original on GitHub ↗

3 Comments

MeGaNeKoS · 1 month ago

Hit this on the CLI (Windows 11, Git Bash MSYS2), plus a couple of adjacent symptoms, and traced the root cause to the openai-codex plugin.

Diagnostic ask for others in this thread and the related ENAMETOOLONG/EOF reports (#83243, #92543, and the various spawn ENAMETOOLONG issues): check whether you have the openai-codex plugin 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:

  1. Unbounded growth alone is enough; no torn-line race required. Your exit-127 case needed two hook processes producing a torn 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.
  2. Same growth, two more crash shapes by byte count:
  • ~8KB+: preamble truncated mid-string inside a single-quoted export, giving unexpected EOF while looking for matching ' at a fixed line (echo hi included). This is #92543.
  • ~32KB+: command line exceeds the Windows CreateProcess limit, Node fails before bash runs, giving Command 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_PATH at a shim that logs GetCommandLineW() verbatim then forwards to real bash. The -c was dominated by dozens of identical CODEX_COMPANION_* exports.

How I fixed it:

  • Filed a PR to make the plugin hook idempotent: openai/codex-plugin-cc#748 (skips appending a line already present in CLAUDE_ENV_FILE).
  • Deduped the already-bloated env files in place, effective on the next Bash call, no restart:
for f in ~/.claude/session-env/*/sessionstart-hook-*.sh; do
  awk '!seen[$0]++' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
done

The plugin fix stops one trigger, but the loader hardening you proposed (dedupe by key on rebuild, and bash -n before caching) is the general fix, since any plugin or user hook can cause it.

kitaekatt · 1 month ago

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:

  • The spliced hook env block is 7739 bytes across 2 files (93 lines).
  • Adding the snapshot source line, the TEMP/TMP exports, the shopt/unalias preamble and the trailing pwd -P >| <tmpfile> brings the bash -c string to roughly 8119 bytes before the user's command is appended.
  • That leaves about 56 bytes of headroom under the ~8175-byte truncation threshold.

So echo probe still 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:

cut inside a quoted string:
  /usr/bin/bash: -c: line 94: unexpected EOF while looking for matching `''   (exit 2)

cut inside the trailing path:
  /usr/bin/bash: line 94: /c/Users/<redacted>/AppData/: Is a directory        (exit 1)

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:

  • Files live at ~/.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 clears filechanged-hook-* and cwdchanged-hook-*, but not these.

To dump the real assembled preamble from inside the Bash tool on Git Bash:

tr '\0' '\n' < /proc/$$/cmdline
kitaekatt · 1 month ago

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=value line per exported variable, on every pass. Since sessionstart-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:

  • Deduplicated, last value wins. This is what bounds the size.
  • Merged with the existing file rather than truncating it, so a pass that exports only some variables cannot drop what an earlier pass exported.
  • A line is kept only if it both matches export NAME= and survives shlex.split. The shape test alone is not enough: export X='oops matches 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.
  • One encoding (UTF-8, LF endings) instead of the platform default, which had differed between call sites.
  • Written from a finally around 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.quote leaves a metacharacter-free value unquoted, which is correct and also smaller. Counting quote characters is not a validity test -- shlex.quote emits 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 way filechanged-* and cwdchanged-* ones are cleared.