Repository navigation
Description
Summary
/codex:setup --enable-review-gate reports success and returns "reviewGateEnabled": true,
but the flag can be written to a state root that the Stop hook never reads. The gate is
then silently inert: no error, no warning, and setup --json keeps affirming it is on.
The split-root mechanism itself is already described in #636 (broker/SessionEnd). This
issue is a different, user-facing consequence of it: an opt-in review gate that fails
open while reporting that it is armed. #676 covers the gate failing open on malformed
hook stdin; this is the same failure class reached through configuration instead.
Mechanism
scripts/lib/state.mjs:29-43 — the state root depends on CLAUDE_PLUGIN_DATA, with a
$TMPDIR fallback. The slug and hash are identical either way; only the root differs.
The two sides of the gate resolve that root in different environments:
| Side | Runs as | CLAUDE_PLUGIN_DATA |
State root used |
|---|---|---|---|
/codex:setup --enable-review-gate |
Bash tool | often unset | $TMPDIR/codex-companion/<slug>-<hash> |
stop-review-gate-hook.mjs:154 |
hook, spawned by the harness | set | $CLAUDE_PLUGIN_DATA/state/<slug>-<hash> |
The plugin is aware of this and tries to bridge it — session-lifecycle-hook.mjs:80:
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]); // <- propagates to the Bash tool
}But appendEnvVar is a no-op whenever process.env.CLAUDE_ENV_FILE is unset, and the
whole propagation never happens at all if the SessionStart hook did not run. Two
reachable paths to that:
- Plugin installed mid-session (
/plugin install+/reload-plugins). The plugin's
SessionStarthook never ran for that session, so nothing was ever appended to
CLAUDE_ENV_FILE. This is exactly the flow a first-time user follows — install the
plugin, then immediately run/codex:setup, which is what the setup command itself
instructs. Every Bash invocation for the rest of that session resolves to$TMPDIR. SessionStartkilled by its own 5s timeout (SessionStart hook is killed by its own 5s timeout, silently dropping CODEX_COMPANION_SESSION_ID #670) — same outcome in an ordinary
session, not just a mid-session install.
hooks/hooks.json gives Stop a 900s timeout but SessionStart only 5s, so path 2 is
not hypothetical.
Repro
Fresh session, plugin installed mid-session, on macOS, plugin 1.0.6, codex-cli 0.149.1:
$ env | grep -E 'CLAUDE_PLUGIN_DATA|CLAUDE_ENV_FILE|CODEX_COMPANION'
(nothing)
$ node .../scripts/codex-companion.mjs setup --json --enable-review-gate
"reviewGateEnabled": true,
"actionsTaken": ["Enabled the stop-time review gate for /Users/me/Projects/alfred-front."],
"nextSteps": []Where the flag actually landed:
$ cat "$TMPDIR/codex-companion/alfred-front-790ce51531842df0/state.json"
{ "version": 1, "config": { "stopReviewGate": true }, "jobs": [] }
$ ls ~/.claude/plugins/data/codex-openai-codex/state/
(empty — the root the Stop hook reads has no state at all)Re-running the same command with the variable set writes a second, independent copy:
$ CLAUDE_PLUGIN_DATA=~/.claude/plugins/data/codex-openai-codex \
node .../scripts/codex-companion.mjs setup --json --enable-review-gate
"actionsTaken": ["Enabled the stop-time review gate for /Users/me/Projects/alfred-front."]
$ cat ~/.claude/plugins/data/codex-openai-codex/state/alfred-front-790ce51531842df0/state.json
{ "version": 1, "config": { "stopReviewGate": true }, "jobs": [] }Same repo, same slug, same hash, two state files, one of them ignored by the hook.
Impact
- The user explicitly opts into a review gate; the gate never fires. Failing open on a
quality gate is worse than failing closed, and here there is no signal at all. setup --jsonis affirmatively misleading:reviewGateEnabled: truereflects the state
file the command just wrote, not the one the hook will read.- The
$TMPDIRfallback is purged by macOS, so even a session where the propagation works
can lose the flag later without the user touching anything. --disable-review-gatehas the mirror-image problem: it can clear a flag in one root
while an armed one persists in the other.
Suggested fixes
Roughly in order of cost:
- Report the resolved path. Add
stateFile(and the resolved root) to thesetup --json
payload and to the human-readable output. A one-line change that makes every split-root
bug in this class self-diagnosing. - Warn when the variable is absent. If
CLAUDE_PLUGIN_DATAis unset while writing
config, emit anextStepswarning rather than silently using$TMPDIR— configuration
is not throwaway state, unlike job files. - Store durable config outside the volatile root.
stopReviewGateis small,
long-lived, per-workspace config;$TMPDIRis the wrong medium for it regardless of
which root wins. A stable location (e.g. under~/.codex/) keyed by workspace hash
removes the divergence for config even if job state stays where it is. - Reconcile on read. Have the
Stophook check the fallback root when the primary has
no state file, and migrate it — also fixes the SessionEnd miss in SessionEnd cannot find the broker when CLAUDE_PLUGIN_DATA differs between spawn and teardown — same cwd, same hash, different state root #636.
Happy to send a PR for (1) and (2) if that direction is useful.
🤖 Generated with Claude Code
Activity
zengzheqing commentedon Aug 30, 2026
One coupling that may matter for triage: the
CLAUDE_PLUGIN_DATA-unset case is not arbitrary — the plugin's own SessionStart hook is what normally prevents it.scripts/session-lifecycle-hook.mjs, inhandleSessionStart:That appends
export CLAUDE_PLUGIN_DATA=...to$CLAUDE_ENV_FILE, so from the second session onward the Bash tool inherits the same root the Stop hook uses, andsetupand the hook agree.This narrows the reproduction: the split is reliably reachable in the session where the plugin is installed — or more generally whenever SessionStart has not run with the plugin present.
/reload-pluginsdoes not re-fire SessionStart, so reloading is not enough.Measured on macOS, plugin 1.0.6, Claude Code 2.1.251:
node -e 'console.log(process.env.CLAUDE_PLUGIN_DATA ?? "UNSET")'→UNSET;setup --enable-review-gatewrites$TMPDIR/codex-companion/<slug>-<hash>/state.json~/.claude/plugins/data/codex-openai-codex;setupand the hook then resolve the same fileThe coupling worth noting: #562 asks for that same export to be removed, as a per-plugin scoping leak. If it is removed without also giving
setupan explicit path to the plugin data root, this issue stops being an install-window bug and becomes permanent behaviour —setupwould always write to$TMPDIRand the gate would always be inert. The two probably need to be fixed together, orsetupneeds to fail loudly whenCLAUDE_PLUGIN_DATAis unset rather than falling back silently.stephan-nb commentedon Sep 8, 2026
A third user-facing consequence of the split root described in #636, in the opposite direction to this issue: here the hook read the real state root and blocked, while the slash commands read the
$TMPDIRfallback and reported an empty, gate-disabled state.So
/codex:statussaidReview gate: disabledandNo jobs recorded yetwhile the file the hook was reading heldstopReviewGate: trueand 50 job records, and/codex:cancel <id>answeredNo job foundfor an ID the hook had printed seconds earlier. Thirteen cancel attempts, none of which could ever have worked. The two commands meant to diagnose a blocked session are unusable exactly when they are needed, and they report a state that contradicts reality with no indication a fallback was taken.Detail:
Summary
resolveStateDir()inscripts/lib/state.mjsfalls back to$TMPDIR/codex-companionwhen theplugin data directory cannot be resolved. That fallback is taken in the slash-command context but
not in the hook context, so
/codex:statusand/codex:canceloperate on an empty state filewhile the Stop hook operates on the real one. Every user-facing diagnostic then contradicts the
hook that is blocking the session.
Environment
openai-codexClaude Code plugin 1.0.6codex@openai-codex, install path~/.claude/plugins/cache/openai-codex/codex/1.0.6What was observed
With the gate blocking session end and naming
task-mtrlaazr-gomnha:/codex:statusreported:While
~/.claude/plugins/data/codex-openai-codex/state/<workspace>/state.jsonheldconfig.stopReviewGate: trueand 50 job records, five of them non-terminal./codex:cancel task-mtrlaazr-gomnhaanswered:for an ID the hook had printed seconds earlier. Thirteen cancel attempts were made; none of them
could ever have worked, because the command was reading a different file.
Steps to reproduce
(see the unreconciled-job-records report).
/codex:statusand compare withjq '{config, jobs: (.jobs | length)}' ~/.claude/plugins/data/codex-openai-codex/state/<workspace>/state.json.The two disagree on both the gate flag and the job count.
Expected
Hook and command contexts resolve the same state directory for the same workspace, so
/codex:statusreflects what the gate reads and/codex:cancelacts on the jobs the gate isblocking on.
Actual
The command context silently uses the
$TMPDIR/codex-companionfallback. It reports an empty,gate-disabled state that looks healthy, which actively misleads: the operator concludes there is
no job and no gate, while the gate blocks every stop attempt.
Impact
The two commands intended to diagnose and clear a blocked session are unusable exactly when they
are needed, and worse than unusable — they report a state that contradicts reality with no
indication that a fallback was taken.
Suggested fix
Two parts, independently useful:
hook into the command surface rather than re-deriving it.
resolveStateDir()cannot resolve the plugin data directory, sayso in the command output, including which path it settled on. A visible
"using fallback state directory
<path>" line would have shortened this from thirteen failedcancels to one.