[BUG] Mods: after a hook throws, .catch's next(e) resumes at the next plugin and skips the failing plugin's remaining hooks
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.submithook dropped thecontextother features attach to the same prompt; - a refused
$.command.registerin onesession.starthook prevented every later feature's commands from registering (users sawUnknown 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))
claude -p "/probe2" --plugin-dir catch-probe --plugin-dir catch-probe-b --debug-file log→PROBE2 OK.claude -p "/probe2 boom" --plugin-dir catch-probe --plugin-dir catch-probe-b --debug-file log→ no hook answered;INNER ranmissing from the log whileB ranis 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 closedline logs onlyerrorChars=, not the message, which made this hard to diagnose (also noted in #101452). - Related: #101452.