MessageDisplay: two non-overlapping hooks sometimes both render, sometimes only one does, and no race/order model found so far explains it

Status Open
Reported on v2.1.238
Maintainer reply None cached
Activity 0 comments · opened Aug 21, 2026

Preflight

  • [x] I searched open and closed issues for duplicates
  • [x] This is a single bug report
  • [x] I am on the latest version (2.1.238)

What's Wrong?

Two independent MessageDisplay hooks that each linkify a different, non-overlapping pattern in the same message do not compose predictably, and the outcome does not match a simple "last hook to finish wins" model either.

This is closely related to #83353 (PreToolUse updatedInput, completion-order race) and #88338 (PostToolUse updatedToolOutput, claimed registration-order). MessageDisplay shows the same kind of nondeterminism, but with an extra wrinkle: sometimes both hooks' edits show up in the rendered output, which a pure "one hook's result overwrites the other" model can't produce no matter which hook is considered the winner.

Repro

settings.json, two hooks that each linkify a different pattern and leave the other's pattern untouched:

{
  "hooks": {
    "MessageDisplay": [
      { "hooks": [ { "type": "command", "command": "/path/to/ticket-linkify.py", "timeout": 5 } ] },
      { "hooks": [ { "type": "command", "command": "/path/to/repo-ref-linkify.py", "timeout": 5 } ] }
    ]
  }
}

ticket-linkify.py (only touches TICKET-1234-style refs):

#!/usr/bin/env python3
import json, re, sys
data = json.load(sys.stdin)
delta = data.get("delta") or ""
out = re.sub(r"\b([A-Z][A-Z0-9]{1,9}-\d+)\b",
             lambda m: f"[{m.group(1)}](https://example.atlassian.net/browse/{m.group(1)})",
             delta)
print(json.dumps({"hookSpecificOutput": {"hookEventName": "MessageDisplay", "displayContent": out}}))

repo-ref-linkify.py (only touches owner/repo#N-style refs), identical shape but matching \b([\w.-]+/[\w.-]+)#(\d+)\b and linking to https://github.com/{owner/repo}/issues/{n}.

Prompt: ask Claude to print a fixed line verbatim, e.g. See ticket TICKET-1234 and someorg/somerepo#7 for details.

Expected

Either both hooks' edits should be visible (since their patterns don't overlap), or the behavior should be documented and consistent.

Actual

Ran the two-hook setup repeatedly (both hook orderings, --keep-style kept tmux sessions, raw escape-sequence capture via tmux capture-pane -p -e), instrumenting each hook script to also log a high-resolution timestamp (time.time() at entry and exit) alongside the turn_id/message_id/index/final fields it receives. 12 samples across both hook orderings:

| Which hook's process finished last | Both links rendered | Only that hook's own link rendered |
|---|---|---|
| the ticket-pattern hook | 5 / 5 | 0 / 5 |
| the repo-ref-pattern hook | 1 / 7 | 6 / 7 |

Two things this rules out:

  1. Not a chunk-splitting artifact. Every single sample shows both hooks receiving the identical turn_id/message_id/index/final for the content flush. They are always racing over the exact same flush chunk, never two independent ones. So "both survived because the two refs landed in different flush events" is not what's happening.
  2. Not a pure "last-to-finish overwrites" race either. If it were, every row above should read "only that hook's own link" 100% of the time, because a hook that only linkifies its own pattern can never produce the other pattern's link by itself. Instead, when the ticket-pattern hook is the one that finishes last, both links show up basically every time, which a pure overwrite model cannot produce under any completion ordering.

The one exception (repo-ref hook finishing last but still showing both) happened at the tightest measured race margin across all samples (about 20 microseconds between the two hook processes' recorded end times), which may just mean the true event-loop-level completion order occasionally disagrees with the self-reported timestamps at that resolution, not a separate mechanism.

Why this matters

Same practical hazard as #83353 and #88338: a hook author who ships a MessageDisplay hook that only touches its own pattern (a very natural way to write a "plugin-scoped" hook) cannot predict, from the hook's own correct behavior, whether their edit will survive when another plugin's hook also fires on the same message. Unlike the other two reports, here even a full accounting of "who finished last" doesn't fully predict the render, so there doesn't appear to be a documentable rule to work around yet, not even an undesirable one.

Claude Code Version

2.1.238

Platform

macOS, plain terminal (not IDE/desktop app)

View original on GitHub ↗