Skip to content

SessionStart hook appends duplicate exports to CLAUDE_ENV_FILE until every Bash call fails #661

Description

@Vadevious

SessionStart hook appends duplicate exports to CLAUDE_ENV_FILE until every Bash call fails

Plugin: codex 1.0.5 (marketplace openai-codex, commit 80c31f9)
Platform: Windows 10 Pro 19045, Claude Code, Git Bash (/usr/bin/bash)

Summary

handleSessionStart appends three export lines to $CLAUDE_ENV_FILE unconditionally. SessionStart fires again on every resume, clear, and compact, so a long-lived session accumulates the same three lines over and over. Claude Code inlines that file into the bash -c prelude for every Bash tool call, and the prelude is truncated past roughly 8 KB. The cut lands mid-string inside a quoted export, so from that point on every Bash invocation in that session fails:

/usr/bin/bash: -c: line 84: unexpected EOF while looking for matching `''

echo ok included. The failure is total and permanent for the affected session — the truncation offset is fixed, so the reported line number never changes and no command is small enough to avoid it.

Cause

scripts/session-lifecycle-hook.mjs:

function appendEnvVar(name, value) {
  if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") {
    return;
  }
  fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8");
}

function handleSessionStart(input) {
  appendEnvVar(SESSION_ID_ENV, input.session_id);
  appendEnvVar(TRANSCRIPT_PATH_ENV, input.transcript_path);
  appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
}

hooks/hooks.json registers SessionStart with no matcher, so it runs for startup, resume, clear and compact alike. session_id, transcript_path and CLAUDE_PLUGIN_DATA are all constant for the life of a session, so every fire after the first writes bytes that were already there.

Reproduction

Resume or compact a session ~28 times (≈286 bytes per fire on this setup; the threshold is bytes, not fires, so longer paths get there sooner). Then any Bash tool call fails as above.

Observed on this machine — ~/.claude/session-env/<session-id>/sessionstart-hook-3.sh:

201 lines, 3 unique, 19162 bytes   # 67 SessionStart fires, broken
 90 lines, 3 unique,  8490 bytes   # broken
 87 lines, 3 unique,  8555 bytes   # broken
 45 lines, 3 unique,  4440 bytes   # still under the limit, works
  3 lines, 3 unique,   286 bytes   # fresh session, works

Eleven of the session-env files on disk had duplicates; the three over ~8 KB were the ones with dead Bash.

Why it is hard to diagnose

The error names bash and a line number in a string the user never wrote, so it reads as a broken shell profile or a broken PreToolUse hook. It is also session-scoped and correlates with session age, so it looks project-specific or intermittent. Nothing in the plugin appears in the error.

Suggested fix

Make appendEnvVar idempotent:

function appendEnvVar(name, value) {
  if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") {
    return;
  }
  const line = `export ${name}=${shellEscape(value)}\n`;
  try {
    if (fs.readFileSync(process.env.CLAUDE_ENV_FILE, "utf8").includes(line)) {
      return;
    }
  } catch {
    // No env file yet (or unreadable) — fall through and write it.
  }
  fs.appendFileSync(process.env.CLAUDE_ENV_FILE, line, "utf8");
}

Verified locally against the real hook with a real CLAUDE_ENV_FILE: five identical SessionStart fires produce 3 lines instead of 15; a subsequent fire with a different session_id correctly appends the two changed exports and skips the unchanged CLAUDE_PLUGIN_DATA; the resulting file sources cleanly under set -e.

Alternatives, if you prefer: rewrite the file rather than append (the values are per-session constants, so there is nothing to accumulate), or gate the hook on matcher: "startup" — though that would drop the exports for resumed sessions.

Workaround for anyone hitting this now

Deduplicate the file; content is preserved, since every line is a duplicate of one of three:

f=~/.claude/session-env/<session-id>/sessionstart-hook-3.sh
awk '!seen[$0]++' "$f" > "$f.tmp" && mv "$f.tmp" "$f"

Takes effect on the next Bash call — no restart needed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions