[BUG] `hookName` is `Event:Matcher` — hooks have no user-visible identity

Status Open
Reported on v2.1.291
Maintainer reply None cached
Activity 0 comments · opened Oct 7, 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?

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 is
hookName, 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_

View original on GitHub ↗