SessionStart hook output is plain text; Codex rejects it as invalid JSON and drops the ruleset
Follow-up to #91, which is now fixed: ${CLAUDE_PLUGIN_ROOT} resolves and the hook no longer exits 1. The hook runs — but Codex still discards it, for a different reason.
Observed
i-have-adhd 0.3.0, codex-cli 0.154.0, Node v25.2.1, macOS.
Every Codex session opens with:
• Hook failed
└ hook returned invalid session start JSON outputThe hook exits 0, but writes plain text to stdout:
$ R=~/.codex/plugins/cache/i-have-adhd/i-have-adhd/0.3.0
$ printf '{"hook_event_name":"SessionStart","cwd":"/tmp","session_id":"t","source":"startup"}' \
| CLAUDE_PLUGIN_ROOT="$R" node "$R/hooks/always-on.mjs"
ADHD MODE ACTIVE (always-on). The ruleset below applies to every response. "stop adhd mode" turns...Claude Code accepts plain stdout from a SessionStart hook and treats it as context. Codex does not — it requires a JSON envelope, and on anything else it drops the entire hook output. The ruleset never reaches the model.
The failure is silent in the way that matters: the error line doesn't name which hook failed, and the plugin looks installed and enabled. On a machine with several plugins it is not obvious that ADHD mode simply isn't on in Codex.
Expected
Either output shape is accepted by Codex:
- empty stdout, or
- a single JSON object:
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"ADHD MODE ACTIVE ..."}}Suggested fix: ship hooks/hooks.codex.json
Codex reads a plugin's hooks/hooks.codex.json in preference to hooks/hooks.json when present. That is the mechanism remember@claude-plugins-official (0.29.1) uses, and it works — its Codex session_start hook returns cleanly on the same machine where this one fails.
That keeps the Claude Code path untouched: hooks.json keeps emitting plain text, and a Codex-only entry emits the envelope. A thin wrapper around the existing always-on.mjs is enough — no change to the ruleset itself.
Verified workaround (not a fix)
For anyone hitting this before it lands: register a wrapper in your own ~/.codex/hooks.json that runs the plugin hook and wraps its stdout.
// envelope.mjs <command> [args...] — runs the command, wraps plain-text stdout in the envelope.
// Empty stdout stays empty; stdout that is already JSON passes through untouched.Confirmed working: the ruleset lands in the session as a role=developer item, verified in
~/.codex/sessions/rollout-*.jsonl.
Two caveats. The plugin's own hook still runs alongside it, so the Hook failed line stays. And if Codex's automatic sync of hooks/skills/plugins is enabled, it removes the wrapper: that sync mirrors the Claude Code configuration into Codex, and a Codex-only entry has no counterpart to mirror, so it is dropped on the next sync. Turning that sync off — keeping only chats and projects synced — makes the wrapper persist.
Edit: an earlier version of this issue blamed a Codex-run cleanup of "non-working hooks" for removing the wrapper. That was wrong; the sync is the cause.
Happy to open a PR with the hooks.codex.json if that shape is welcome.
Source: ayghri/i-have-adhd