[BUG] `hookName` is `Event:Matcher` — hooks have no user-visible identity
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?
What I expected
When a hook fires, it should be possible to tell which hook ran — similar to
how a skill invocation shows the skill's name. With several hooks registered on
the same event, I want to know which one produced the output I'm seeing.
What actually happens
There is no way to identify a hook. The only name the runtime attaches ishookName, and it is synthesized from the event and the matcher — nothing the
author or user controls.
Reproduction
Register three hooks on the same matcher, each a different script with a
different exit code.
~/.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Read",
"hooks": [
{ "type": "command", "command": "bash /tmp/hookvis/a.sh", "timeout": 5 },
{ "type": "command", "command": "bash /tmp/hookvis/b.sh", "timeout": 5 },
{ "type": "command", "command": "bash /tmp/hookvis/c.sh", "timeout": 5 }
]
}
]
}
}
- a.sh → echo "TEST-A exit0 stdout"; exit 0
- b.sh → echo "TEST-B exit2 stderr" >&2; exit 2
- c.sh → echo "TEST-C exit1 stdout"; exit 1
Trigger any Read, then read the attachment records in the session
transcript (~/.claude/projects/<project>/<session>.jsonl).
Observed
┌─────────────────────────┬──────┬────────┬──────────────────────┬──────────────────┬─────────┐
│ attachment type │ exit │ stream │ rendered │ hookName │ command │
├─────────────────────────┼──────┼────────┼──────────────────────┼──────────────────┼─────────┤
│ hook_success │ 0 │ stdout │ null │ PostToolUse:Read │ present │
├─────────────────────────┼──────┼────────┼──────────────────────┼──────────────────┼─────────┤
│ hook_blocking_error │ 2 │ stderr │ renderedRole: system │ PostToolUse:Read │ absent │
├─────────────────────────┼──────┼────────┼──────────────────────┼──────────────────┼─────────┤
│ hook_non_blocking_error │ 1 │ stdout │ null │ PostToolUse:Read │ present │
└─────────────────────────┴──────┴────────┴──────────────────────┴──────────────────┴─────────┘
Three different scripts, three different exit codes — one identical hookName.
Nothing in these records distinguishes them by identity. The
hook_blocking_error record is thinner still: it carries no command field,
so its only hint of origin is the [bash /tmp/hookvis/b.sh] prefix the runtime
inlines into the blockingError text.
Consequences:
- A user cannot tell which hook fired.
- A user cannot tell how many hooks fired — N hooks on one matcher collapse
into a single name.
- Only exit 2 + stderr reaches the model's context. stdout on success (exit 0)
and on non-blocking errors (exit 1) render as null, so both are invisible
to the user and to the model.
The schema gap
The hook entry schema accepts type, command, args, timeout,
statusMessage, once, if, disabled, plus per-transport fields. There is
no name or description, and unknown fields are ignored.
statusMessage is documented as "Custom spinner message displayed while the
hook runs" — it is visible only for the duration of the run, and it is free
text each hook must maintain separately; it carries no source information.
systemMessage is the documented channel for surfacing a message to the user,
but the hook itself must emit it, so it identifies the hook only if every hook
author remembers to add one.
/hooks lists registrations grouped by source (user / project / local /
plugin / session). That helps for auditing what is installed, but not for
"what just ran".
Request
Either:
1. Add a name field to hook entries, shown in the ⎿ line and in the
debug log alongside (or in place of) Event:Matcher. This names the hook
the way its author thinks about it, and works for hooks from any source.
2. Include the registration source in hookName — the settings file path,
or the plugin name for plugin-provided hooks — so hooks from different
sources are at least distinguishable. This needs no authoring changes.
Option 1 is closer to what a user wants; option 2 is the cheaper change and
still separates user hooks from plugin hooks.
Environment
- Claude Code 2.1.291
- Rocky Linux 9.4 x86_64
Related
- #11224 — PostToolUse hook output visibility depends on exit code and stream
(suggests a visibility: user-only | claude-only | both | none field)
- #76736 — VS Code extension renders no hook text output
(systemMessage, statusMessage, additionalContext)
### What Should Happen?
When a hook fires, it should be possible to tell which hook ran — similar to
how a skill invocation shows the skill's name. With several hooks registered on
the same event, I want to know which one produced the output I'm seeing.
### Error Messages/Logs
```shell
Steps to Reproduce
Register three hooks on the same matcher, each a different script with a
different exit code.
~/.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Read",
"hooks": [
{ "type": "command", "command": "bash /tmp/hookvis/a.sh", "timeout": 5 },
{ "type": "command", "command": "bash /tmp/hookvis/b.sh", "timeout": 5 },
{ "type": "command", "command": "bash /tmp/hookvis/c.sh", "timeout": 5 }
]
}
]
}
}
- a.sh → echo "TEST-A exit0 stdout"; exit 0
- b.sh → echo "TEST-B exit2 stderr" >&2; exit 2
- c.sh → echo "TEST-C exit1 stdout"; exit 1
Trigger any Read, then read the attachment records in the session
transcript (~/.claude/projects/<project>/<session>.jsonl).
Observed
┌─────────────────────────┬──────┬────────┬──────────────────────┬──────────────────┬─────────┐
│ attachment type │ exit │ stream │ rendered │ hookName │ command │
├─────────────────────────┼──────┼────────┼──────────────────────┼──────────────────┼─────────┤
│ hook_success │ 0 │ stdout │ null │ PostToolUse:Read │ present │
├─────────────────────────┼──────┼────────┼──────────────────────┼──────────────────┼─────────┤
│ hook_blocking_error │ 2 │ stderr │ renderedRole: system │ PostToolUse:Read │ absent │
├─────────────────────────┼──────┼────────┼──────────────────────┼──────────────────┼─────────┤
│ hook_non_blocking_error │ 1 │ stdout │ null │ PostToolUse:Read │ present │
└─────────────────────────┴──────┴────────┴──────────────────────┴──────────────────┴─────────┘
Three different scripts, three different exit codes — one identical hookName.
Nothing in these records distinguishes them by identity. The
hook_blocking_error record is thinner still: it carries no command field,
so its only hint of origin is the [bash /tmp/hookvis/b.sh] prefix the runtime
inlines into the blockingError text.
Consequences:
- A user cannot tell which hook fired.
- A user cannot tell how many hooks fired — N hooks on one matcher collapse
into a single name.
- Only exit 2 + stderr reaches the model's context. stdout on success (exit 0)
and on non-blocking errors (exit 1) render as null, so both are invisible
to the user and to the model.
The schema gap
The hook entry schema accepts type, command, args, timeout,
statusMessage, once, if, disabled, plus per-transport fields. There is
no name or description, and unknown fields are ignored.
statusMessage is documented as "Custom spinner message displayed while the
hook runs" — it is visible only for the duration of the run, and it is free
text each hook must maintain separately; it carries no source information.
systemMessage is the documented channel for surfacing a message to the user,
but the hook itself must emit it, so it identifies the hook only if every hook
author remembers to add one.
/hooks lists registrations grouped by source (user / project / local /
plugin / session). That helps for auditing what is installed, but not for
"what just ran".
Claude Model
None
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
2.1.291
Platform
Anthropic API
Operating System
Other Linux
Terminal/Shell
Terminal.app (macOS)
Additional Information
_No response_