[BUG] SubagentStop hook fires before agent_transcript_path is fully flushed to disk
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?
The SubagentStop hook receives agent_transcript_path in its payload, but the transcript JSONL file might not be fully written when a script the hook calls reads it (depends on timing). The final 1-2 JSONL entries (the agent's last assistant message) are missing from the file on disk, even though they appear in the transcript shortly after.
This means any hook that reads agent_transcript_path to extract the agent's final output could get stale/incomplete data depending on timing. In my case, a hook that extracts the last assistant text message from the transcript gets an earlier intermediate message instead of the actual final output.
Sub-millisecond file timestamp analysis shows a 100% correlation: every broken output was written when the section file's mtime was before the transcript's mtime (hook read the file before the final entry landed on disk). Every correct output was written when the section file's mtime was after the transcript's mtime.
Section Status Section file mtime Transcript mtime Diff
─────────────────────────────────────────────────────────────────────────────────────────
section-08 BROKEN 22:23:00.472 22:23:00.488 -16ms
section-10 BROKEN 22:23:02.573 22:23:02.617 -44ms
section-13 BROKEN 22:23:02.338 22:23:02.354 -15ms
section-09 OK 22:23:07.098 22:23:07.095 +3ms
section-11 OK 22:23:26.715 22:23:26.671 +45ms
section-12 OK 22:22:34.773 22:22:34.756 +16ms
section-14 OK 22:23:15.866 22:23:15.856 +10ms
The race window is 15-44ms. Observed failure rate: 9/14 subagents (64%) across 2 sessions.
What Should Happen?
When a SubagentStop hook fires and reads the agent_transcript_path provided in the payload, the file should contain all entries including the agent's final assistant message. The transcript should be fully flushed (fsync or equivalent) before the hook is dispatched.
Error Messages/Logs
No error — the hook runs successfully and writes a file, but with wrong content because the transcript it read was incomplete. The failure is silent.
Verification that the transcript IS eventually complete (running the same parser after the fact):
# At hook execution time: parser finds 163 chars (intermediate commentary)
# After the fact (transcript fully flushed): parser finds 16,081 chars (actual content)
Simulating what the hook saw by removing the last 2 JSONL lines reproduces the exact broken output byte-for-byte:
# Full transcript (92 lines) → parser finds line 91 (16,081 chars) ✓
# Minus last 2 lines (90 lines) → parser finds line 86 (163 chars) ✗
# Content on disk: 163 chars — exact match with minus-2-lines result
Steps to Reproduce
- Create a plugin with a
SubagentStophook that readsagent_transcript_path:
{
"hooks": {
"SubagentStop": [
{
"matcher": "my-agent-type",
"hooks": [
{
"type": "command",
"command": "python3 read_transcript.py"
}
]
}
]
}
}
- Create
read_transcript.pythat reads the transcript and extracts the last assistant message:
import json, sys
payload = json.loads(sys.stdin.read())
transcript_path = payload["agent_transcript_path"]
last_assistant_text = None
with open(transcript_path) as f:
for line in f:
entry = json.loads(line)
msg = entry.get("message", {})
if msg.get("role") == "assistant":
content = msg.get("content", [])
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
if text.strip():
last_assistant_text = text
# This frequently returns an INTERMEDIATE assistant message,
# not the final one, because the final entry hasn't been
# flushed to disk yet.
print(f"Got {len(last_assistant_text)} chars")
- Create a custom agent type with
tools: Read, Grep, Globthat does several tool calls then outputs a large final text message (~15-20KB).
- Launch multiple instances via the
Tasktool in parallel (7 in my case), though the bug could also reproduce with isolated single agents.
- Observe that the hook frequently reads an incomplete transcript — the final assistant text entry is missing.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.38
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
iTerm2
Additional Information
Workaround
Polling the transcript file size until it stabilizes (200ms with no changes) before reading works reliably. The race window is 15-44ms, so 200ms gives 4-13x safety margin:
import os, time
def wait_for_stable_file(path, stability_ms=200, timeout_s=5.0, poll_ms=50):
deadline = time.time() + timeout_s
last_size = -1
stable_since = time.time()
while time.time() < deadline:
size = os.path.getsize(path)
if size != last_size:
last_size = size
stable_since = time.time()
elif (time.time() - stable_since) >= stability_ms / 1000:
return
time.sleep(poll_ms / 1000)
Suggested fix
Ensure the transcript file is fully flushed (e.g., fd.sync() / fsync) before dispatching SubagentStop hooks. The payload explicitly provides agent_transcript_path, which creates a contract that the file should be readable and complete.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗