SessionStart hook output is plain text; Codex rejects it as invalid JSON and drops the ruleset

Author: Stropper1980Created Sep 10, 2026Updated Sep 10, 2026

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 output

The hook exits 0, but writes plain text to stdout:

bash
$ 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:
json
{"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.

javascript
// 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.