Hooks: async command hooks are skipped, so Claude-format plugin hooks silently lose their background handlers

Open 💬 0 comments Opened Jul 22, 2026 by nhadaututtheky

What happens

Codex discovers a plugin's hooks/hooks.json (the Claude Code plugin layout) and runs its hooks, but any entry with "async": true is dropped at load time:

⚠ skipping async hook in ~/.codex/plugins/cache/<owner>/<plugin>/<version>/hooks/hooks.json: async hooks are not supported yet

Verified on codex-cli 0.145.0 (Windows 11).

Why this is more than a warning

Hook files are shared, not per-platform. Codex reads <plugin>/hooks/hooks.json — the same path Claude Code reads — and the plugin manifest has no key to point the two runtimes at different files. So a plugin that ships one hooks file for both runtimes has to choose:

  • keep async: true → the hook is silently inactive on Codex
  • drop it → the hook becomes blocking on Claude Code, where async is honored and these handlers are background work

Neither option is correct, and the failure mode is quiet: the hook still appears in hooks.state in config.toml, so from the outside it looks wired.

Concretely, in Rune this is 6 of 11 hooks — prompt routing, context tracking, formatting, typecheck, metrics, and untrusted-content quarantine. All of them are fire-and-forget observers that have no business blocking a turn, which is exactly why they are marked async, and exactly why they do not run on Codex.

What I'd expect

async: true on a command hook means: spawn it, do not wait, ignore its stdout/exit code for control-flow purposes. It never participates in permissionDecision, decision:block, updatedInput, or any other interception contract — a hook that opts out of blocking also opts out of influencing the transition.

That keeps the semantics narrow enough to avoid the hard parts of the hook parity tracker ("Async hooks | Supported where transition does not need to block" in its Runtime Matrix): no output contract, no ordering guarantees, no new config surface. Two things worth pinning down:

  1. Lifetime — does an async hook get killed when the turn ends, or is it allowed to outlive it? Claude Code lets it run on; either is workable, but it should be documented rather than discovered.
  2. Post-Stop hooks — if async hooks are reaped at session end, an async Stop handler is a footgun; rejecting async on Stop specifically would be clearer than silently reaping it.

Meanwhile

If async support is further out than this issue's lifetime, a smaller win would be making the skip visible in codex doctor rather than only as startup stderr — right now the only signal is a warning that scrolls past before the session starts, and nothing afterwards reports that N hooks are inert.

Repro

// <plugin>/hooks/hooks.json
{
  "hooks": {
    "PostToolUse": [
      { "matcher": ".*",
        "hooks": [ { "type": "command", "command": "node ./observe.cjs", "async": true } ] }
    ]
  }
}

Install the plugin, start codex → warning at startup, observe.cjs never runs. Remove "async": true → it runs.

View original on GitHub ↗