PreToolUse hooks have no race-free way to read the assistant's current-turn text; transcript_path lag can cause a gating hook to act on stale content

Status Open
Maintainer reply None cached
Activity 1 comment · opened Aug 17, 2026

Summary

transcript_path is documented as written asynchronously and may lag the
in-memory conversation ("may not yet include the current turn's most recent
messages when a hook fires"). For Stop / SubagentStop / StopFailure
this is fully mitigated: those events also carry last_assistant_message
in the hook's stdin payload, so a hook doesn't need to parse the transcript
file at all (see #74340, where this was the accepted fix for the read-aloud
use case).

PreToolUse hooks receive only tool_name, tool_input, and
tool_use_id in addition to the common fields — there is no equivalent of
last_assistant_message. A PreToolUse hook that needs to inspect the text
Claude just wrote in the current turn (for example, a compliance/content
check that must run before a tool call is allowed to proceed) has no
official race-free option: it has to read transcript_path, which is the
exact thing the docs warn is not guaranteed to be current.

Observed effect

A PreToolUse hook that reads transcript_path to check the just-written
assistant text against a policy list can intermittently see the previous
turn's text instead of the current one, because the tool call and the
transcript write race each other. In our case this took the form of a
content-filter hook that blocks a response containing a disallowed word:
the hook fired, read the transcript, and matched the word from an earlier
(already-corrected) draft of the reply rather than the actual, clean,
final text — producing a block with no real cause. From the outside this
looks like "the hook is broken" or "the text still has the word," which is
misleading and expensive to debug, because the real cause (a write/read
race on the transcript file) isn't visible without instrumenting the
timing.

Minimal reproduction (simulated, no live model calls needed)

The race can be reproduced deterministically by simulating the two writes
a real turn produces — an initial version of a transcript line, then a
delayed second version — and reading in between:

  1. Write a JSONL transcript file whose last line represents an assistant

message containing text A.

  1. After a short delay (in our testing, ~150ms was enough to reliably

reproduce it), overwrite that same line with the "real" final version
containing text B instead of A.

  1. Have a hook script read transcript_path and extract the last

assistant text immediately after being invoked, i.e. before step 2 has
necessarily completed.

  1. The hook intermittently reads text A (stale) instead of text B

(current), depending on exactly when its read lands relative to the
delayed write.

This is the same class of race as #74340, just observed on PreToolUse
instead of Stop, and with no documented field to fall back to.

Hypothesis

Race between (a) the engine finishing the write of the current turn's
transcript entry and (b) the PreToolUse hook process being spawned and
reading transcript_path. The hook is not guaranteed to run after the
write is flushed, matching the existing documented caveat on
transcript_path — but that caveat only ships a fix for Stop-family
events.

What we did as a workaround

On the specific code path that would otherwise result in a block, add a
short bounded retry with backoff before treating a match as final (in our
case: re-read + re-check up to 4 times, ~80ms apart, only on the path that
is about to block — so normal, non-matching invocations pay no extra
latency). This eliminated the false positives in our simulation. This is a
workaround, not a fix — it feels like something hook authors shouldn't
each have to reinvent.

Suggested fix (any of these would close the gap)

  1. Document the transcript_path race explicitly for PreToolUse (and

other non-Stop-family events) rather than only in the context of the
last_assistant_message fix for Stop/SubagentStop/StopFailure,
and recommend a specific retry-with-backoff pattern for hooks that must
read the transcript directly.

  1. Guarantee the transcript write for the current turn is flushed before

any hook for that turn is invoked, removing the race at the source.

  1. Expose a PreToolUse equivalent of last_assistant_message (the text

blocks that immediately preceded the tool call in the current turn),
so gating/content-filter hooks don't need to touch the transcript file
at all.

Related

  • #74340 — same underlying race, but scoped to Stop/read-aloud, already

has an accepted fix via last_assistant_message. This issue is the
PreToolUse case that fix doesn't cover.

  • #15813 — closed/locked duplicate of the Stop case.
  • #81825 — another instance of the Stop case, in a specific plugin.

Environment

Generic — reproduced via the simulated-delay repro above, independent of
OS/model/platform specifics.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗