PreToolUse hooks read a transcript that lacks the current assistant message until the tool result lands
Summary
A PreToolUse hook that reads transcript_path sees, in some sessions, a transcript that ends at the previous message for the hook's whole life: the assistant message carrying the tool_use under review is written only after the tool's result is appended. In other sessions on the same machine, with the same hooks, the same message is written within about a second of the hook firing. The hooks reference says the transcript "is written asynchronously and may lag the in-memory conversation"; this report traces the lag to the writer's hold condition and shows that in the first kind of session no wait bound helps, because the write that carries the message is the write that carries the tool's result.
Environment
- macOS (Darwin 25.6.0), interactive REPL, permission mode
auto, effortxhighin both sessions. - Session A: Claude Code 2.1.280, model
claude-fable-5-1, session7a1caa1e-d023-4d75-8ab0-cc8915c19c10. - Session B: Claude Code 2.1.283, model
claude-opus-5-5, sessionb28643a1-2127-4508-bd66-5a26b48abf17, cwd a git worktree of the same repository. - The mechanism below was read from the installed binary, 2.1.292. The same behaviour has been observed on earlier builds, and the session that writes promptly is on the older build, so the build is not the variable.
- The hook: a PreToolUse hook on
Edit|Writethat indexes the transcript, polls it every 100 ms for the line whosetool_useid is the payload'stool_use_id, gives up after 50 polls, and logs how the wait ended.
Measurements
| session | waits that saw the call | waits that ended behind at 50 polls |
|---|---|---|
| A | 425 | 2 |
| B | 40 | 472 |
In every sampled behind entry of B the transcript carried no line with the call's id at the bound; the newest call on disk was the previous message's. B's 40 caught-up waits fall into two shapes: 15 saw the call at 0 polls, on a message whose call sat at block 2 or later with every block, later ones included, already on disk; the rest took 6 to 10 polls, on a call at block 0 with exactly one more block behind it. In both shapes the message carried a second tool whose result landed first.
Transcript evidence
Session A, one Edit under review (times are each line's timestamp, the record's creation time; line numbers are file order, which is write order):
| line | kind | created |
|---|---|---|
| 2170 | assistant record, block 0 | 18:42:05.846 |
| 2171 | assistant record, block 1 | 18:42:05.850 |
| 2172 | assistant record, block 2, the Edit | 18:42:14.801 |
| 2173 to 2177 | last-prompt, ai-title, mode, permission-mode, atis-latch | |
| 2178 | attachment hook_success | 18:42:15.110 |
| 2179 to 2182 | attachments hook_success, hook_additional_context | 18:42:35.908 to 35.962 |
| 2183 | user, the tool result | 18:43:51.038 |
The five metadata lines are the writer's per-batch trailer, so the Edit's record was written in a batch that closed before the first hook finished, 309 ms after the record was created.
Session B, the Edit under a 118 s review:
| line | kind | created |
|---|---|---|
| 109437 | assistant record, block 0 | 14:35:03.606 |
| 109438 | assistant record, block 1 | 14:35:03.607 |
| 109439 | assistant record, block 2, the Edit | 14:35:07.753 |
| 109440 | attachment hook_success | 14:35:08.078 |
| 109441 | attachment hook_additional_context | 14:37:05.866 |
| 109442 | user, the tool result | 14:37:05.868 |
No trailer stands between the record and the attachments: one batch, written when the result landed. The hook polled from 14:35:07.8 for 5 s and saw no line with the call's id. The hook_success attachment created at +0.3 s was written in that same batch, so hook attachments are appended to the conversation with the tool's result, not when the hook completes.
Both messages are three blocks with the Edit last. Every assistant record in both transcripts was written with its final stop_reason ("tool_use"); the transcripts carry 8 and 6 lines with "stop_reason":null in total.
Mechanism, from the 2.1.292 binary
- Transcript writes are driven by an effect on the messages array (
Nke, which callsOke.write, which callsIv, which diffs the array against the uuids already on disk and appends the rest throughinsertMessageChain). The effect re-runs when the array changes. TheisResponseInFlightoption it passes is the REPL'sisLoading, the same value handed to the UI asisLoading. - Each write is bounded by this function, with
rthe in-flight flag:
function FWt(e,n,r){if(!r)return e.length;for(let s=n;s<e.length;s++){let g=e[s];if(g.type==="assistant"&&g.message.stop_reason===null)return s}return e.length}
While the turn is loading, nothing from the first assistant record with a null stop_reason onward is written.
- Per-block assistant records are built in the stream loop at
content_block_stopas{message:{...Nf,content:bl},…,apiBlockIndex:bs.index}, a copy of the in-progress message, whosestop_reasonis null until the message ends. They are finalized later, in place. - The streaming tool executor queues every
tool_useas its record arrives (addTool) and starts it as soon as nothing non-concurrency-safe is executing (canExecuteTool), so PreToolUse fires while the message's records are held. - The REPL's stream-event handler (
HKr) appends no message atmessage_deltaormessage_stop; it only updates stream mode, metrics, and streaming display state. - So the held records are written on the next change to the messages array after the message ends. In B, for a message whose only tool is the Edit, that change is the Edit's own result. In A something changes the array within about 300 ms of the stream's end; it is not a new message, since the record's batch trailer follows the record directly, and it has not been identified.
Ruled out
- The build: A, which writes promptly, is the older of the two.
- Permission mode and effort: both sessions
autoandxhigh. - The thinking display: A's transcript carries server summary blocks and B's carries none, the inverse of what a summary-side delay would need.
- API context management: present in both transcripts.
- The out-of-line
tool-results/store: it holds tool outputs written after the tool runs; the assistant records are inline.
Impact
A hook that reviews the agent's current message, its reasoning and its statement for the call under review, which is the use transcript_path invites, reads a window ending at the previous message, and no poll bound changes that. The PreToolUse input carries no field with the message; last_assistant_message exists only on Stop and SubagentStop, after the call has run.
Ask
- Flush the held transcript records when the assistant message finalizes, or run the writer before PreToolUse hooks are dispatched, so
transcript_pathcarries the message under review when a hook reads it. - Failing that, add the current assistant message, its blocks so far with the
tool_use, to the PreToolUse hook input. - Either way, document the contract: what
transcript_pathis guaranteed to carry when PreToolUse fires.
The one workaround observed is the shape of B's 40 caught-up waits: a second, concurrency-safe tool in the same message, whose result changes the array and flushes the message within about a second.