[BUG] Mods: after a hook throws, .catch's next(e) resumes at the next plugin and skips the failing plugin's remaining hooks

Status Open
Reported on v2.1.296
Maintainer reply None cached
Activity 0 comments · opened Oct 12, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

In a mod (function-hooks plugin) that registers several hooks on the same event, when one hook throws before calling next and its .catch(($, e, next) => next(e)) handler answers, the chain continues from the next plugin. The failing plugin's own remaining hooks for that event never run. Other plugins' hooks and the engine run normally.

Because hooks.json takes a single hooks module, a plugin built from several features registers one hook per feature on shared events, so one optional feature's failure silently removes unrelated features of the same plugin. In our plugin this happened twice:

  • a failed model call in one prompt.submit hook dropped the context other features attach to the same prompt;
  • a refused $.command.register in one session.start hook prevented every later feature's commands from registering (users saw Unknown command) — see #101452.

The .catch documentation (reference.md) explains next.called and that next(e) replays a settled call, but does not say which hooks next(e) reaches when the hook failed before next.

What Should Happen?

Either next(e) from the handler continues with the failing plugin's next hook for the event, or the documentation states that the rest of the failing plugin's hooks are skipped (and claude plugin validate could note plugins that register several hooks on one event).

Error Messages/Logs

# /probe2 boom (outer hook throws; inner hook of the same plugin should answer)
catch-probe registered /probe2 but no command.run hook answered it: add on("command.run", { command: "probe2" }, ...) to the plugin.

# debug log
[catch-probe] $.ui.log: OUTER ran args=boom
[catch-probe] $.ui.log: CATCH ran called=false
[catch-probe-b] $.ui.log: B ran args=boom beneath=0
[catch-probe-b] $.ui.log: B after next trace=<another installed plugin>:returned,engine:returned
[WARN] hook failed closed: catch-probe: errorKind=Error errorChars=10 (command.run; its .catch answered)
# "INNER ran" never appears

# control, /probe2 (no throw)
PROBE2 OK args=
# log: OUTER ran -> INNER ran -> "catch-probe (user) answered command.run without next() ...; nothing beneath it ran"

Steps to Reproduce

Two throwaway plugins, slash command only (no model call):

// plugin "catch-probe"
on('session.start', async ($, e, next) => {
  await $.command.register({ name: 'probe2', description: 'probe' })
  return next(e)
})
on('command.run', async ($, e, next) => {             // outer: throws before next when args === 'boom'
  $.ui.log(`OUTER ran args=${e.args}`)
  if (e.args === 'boom') throw new Error('outer boom')
  return next(e)
}).catch(($, e, next) => {
  $.ui.log(`CATCH ran called=${String(next.called)}`)
  return next(e)
})
on('command.run', { command: 'probe2' }, async ($, e) => { // inner: same plugin, answers the command
  $.ui.log(`INNER ran args=${e.args}`)
  return { text: `PROBE2 OK args=${e.args}` }
})

// plugin "catch-probe-b" (a different plugin)
on('command.run', async ($, e, next) => {
  $.ui.log(`B ran args=${e.args}`)
  const result = await next(e)
  $.ui.log(`B after next trace=${next.trace.map(t => `${t.plugin}:${t.outcome}`).join(',')}`)
  return result
}).catch(($, e, next) => next(e))
  1. claude -p "/probe2" --plugin-dir catch-probe --plugin-dir catch-probe-b --debug-file log → PROBE2 OK.
  2. claude -p "/probe2 boom" --plugin-dir catch-probe --plugin-dir catch-probe-b --debug-file log → no hook answered; INNER ran missing from the log while B ran is present.

The same happens in claude plugin test.

Claude Model

Not sure / Multiple models

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.296 (Claude Code)

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Non-interactive/CI environment

Additional Information

  • Workaround: make shared-event hooks never throw (wrap every external call in try/catch inside the hook), which defeats the purpose of .catch.
  • The hook failed closed line logs only errorChars=, not the message, which made this hard to diagnose (also noted in #101452).
  • Related: #101452.

View original on GitHub ↗