Summary
The SessionStart hook writes this plugin's own CLAUDE_PLUGIN_DATA into $CLAUDE_ENV_FILE, which is shared by every plugin in the session. Because that file is sourced by all subsequent Bash tool calls, the last SessionStart hook to run wins the variable for the whole session — for every consumer, not just its owner.
When two plugins that both do this are installed (e.g. this one and another Claude Code companion), background jobs launched from Bash land in the other engine's state directory. At SessionEnd, that other engine's cleanupSessionJobs finds them (same session_id), calls terminateProcessTree() and prunes them from its state — so the job dies with no error surfaced, and a later status/result call answers No job found for "<id>".
Where
plugins/<name>/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");
}
...
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]); // <-- leaks to every plugin
SESSION_ID_ENV and TRANSCRIPT_PATH_ENV are namespaced (<PLUGIN>_COMPANION_SESSION_ID), so they do not collide. CLAUDE_PLUGIN_DATA is the only generic name being exported into the shared file.
Why it matters
Claude Code already provides the correct per-plugin value to each hook invocation. The leak only affects Bash-launched child processes, which is precisely where the companion CLI runs. Consequences observed:
- Jobs are written to a foreign state directory (
resolveStateDir() derives it from $CLAUDE_PLUGIN_DATA).
- The foreign plugin's
SessionEnd terminates and prunes them, since both filter on job.sessionId === sessionId.
- The failure is silent and non-deterministic — it only bites if some session ends while a long-running job is still polling, which makes it hard to attribute. In a setup with several concurrent sessions this happens regularly.
- Symmetric: whichever plugin wins the variable, the other one's jobs are the casualties.
Reproduction
- Install two Claude Code plugins that both export
CLAUDE_PLUGIN_DATA from their SessionStart hook.
- In a session, from a Bash tool call:
echo "$CLAUDE_PLUGIN_DATA" → it points at whichever plugin's hook ran last, not necessarily the one you are about to invoke.
- Launch a background job through this plugin's companion from Bash, then read its
logFile via status --json → the path is under the other plugin's data directory.
- End another session in the same workspace while the job is queued/running → the job is terminated and pruned;
result <jobId> then answers No job found.
Step 3 alone is enough to confirm the leak; step 4 reproduces the resulting job loss.
Notes for whoever picks this up
Two obvious fixes both have a catch, so I am reporting the defect rather than proposing a patch:
- Simply dropping the
appendEnvVar(PLUGIN_DATA_ENV, …) line changes behaviour: with the variable unset, resolveStateDir() falls back to FALLBACK_STATE_ROOT_DIR (os.tmpdir()/<plugin>), so jobs move to the temp directory instead of the plugin data directory.
- Renaming to a namespaced variable (e.g.
<PLUGIN>_PLUGIN_DATA) is not purely local: at least one other published plugin reads the generic CLAUDE_PLUGIN_DATA and throws when it is missing, so a rename by the writers without a matching fallback in the readers would break it.
A direction that avoids both traps is to pass the value explicitly to the child process at spawn time (env of the spawned command) instead of publishing it into the shared session env file — the value is already known to the plugin at that point.
Happy to test a patch against a multi-plugin setup if that helps.
Summary
The
SessionStarthook writes this plugin's ownCLAUDE_PLUGIN_DATAinto$CLAUDE_ENV_FILE, which is shared by every plugin in the session. Because that file is sourced by all subsequent Bash tool calls, the lastSessionStarthook to run wins the variable for the whole session — for every consumer, not just its owner.When two plugins that both do this are installed (e.g. this one and another Claude Code companion), background jobs launched from Bash land in the other engine's state directory. At
SessionEnd, that other engine'scleanupSessionJobsfinds them (samesession_id), callsterminateProcessTree()and prunes them from its state — so the job dies with no error surfaced, and a laterstatus/resultcall answersNo job found for "<id>".Where
plugins/<name>/scripts/session-lifecycle-hook.mjsSESSION_ID_ENVandTRANSCRIPT_PATH_ENVare namespaced (<PLUGIN>_COMPANION_SESSION_ID), so they do not collide.CLAUDE_PLUGIN_DATAis the only generic name being exported into the shared file.Why it matters
Claude Code already provides the correct per-plugin value to each hook invocation. The leak only affects Bash-launched child processes, which is precisely where the companion CLI runs. Consequences observed:
resolveStateDir()derives it from$CLAUDE_PLUGIN_DATA).SessionEndterminates and prunes them, since both filter onjob.sessionId === sessionId.Reproduction
CLAUDE_PLUGIN_DATAfrom theirSessionStarthook.echo "$CLAUDE_PLUGIN_DATA"→ it points at whichever plugin's hook ran last, not necessarily the one you are about to invoke.logFileviastatus --json→ the path is under the other plugin's data directory.result <jobId>then answersNo job found.Step 3 alone is enough to confirm the leak; step 4 reproduces the resulting job loss.
Notes for whoever picks this up
Two obvious fixes both have a catch, so I am reporting the defect rather than proposing a patch:
appendEnvVar(PLUGIN_DATA_ENV, …)line changes behaviour: with the variable unset,resolveStateDir()falls back toFALLBACK_STATE_ROOT_DIR(os.tmpdir()/<plugin>), so jobs move to the temp directory instead of the plugin data directory.<PLUGIN>_PLUGIN_DATA) is not purely local: at least one other published plugin reads the genericCLAUDE_PLUGIN_DATAand throws when it is missing, so a rename by the writers without a matching fallback in the readers would break it.A direction that avoids both traps is to pass the value explicitly to the child process at spawn time (env of the spawned command) instead of publishing it into the shared session env file — the value is already known to the plugin at that point.
Happy to test a patch against a multi-plugin setup if that helps.