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
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, andtool_use_id in addition to the common fields — there is no equivalent oflast_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:
- Write a JSONL transcript file whose last line represents an assistant
message containing text A.
- 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.
- Have a hook script read
transcript_pathand extract the last
assistant text immediately after being invoked, i.e. before step 2 has
necessarily completed.
- 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 ontranscript_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)
- Document the
transcript_pathrace explicitly forPreToolUse(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.
- Guarantee the transcript write for the current turn is flushed before
any hook for that turn is invoked, removing the race at the source.
- Expose a
PreToolUseequivalent oflast_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
Stopcase. - #81825 — another instance of the
Stopcase, in a specific plugin.
Environment
Generic — reproduced via the simulated-delay repro above, independent of
OS/model/platform specifics.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗