[BUG] SubagentStop hook fires before agent_transcript_path is fully flushed to disk

Resolved 💬 2 comments Opened Feb 11, 2026 by piercelamb Closed Feb 11, 2026

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

  1. Create a plugin with a SubagentStop hook that reads agent_transcript_path:
{
  "hooks": {
    "SubagentStop": [
      {
        "matcher": "my-agent-type",
        "hooks": [
          {
            "type": "command",
            "command": "python3 read_transcript.py"
          }
        ]
      }
    ]
  }
}
  1. Create read_transcript.py that 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")
  1. Create a custom agent type with tools: Read, Grep, Glob that does several tool calls then outputs a large final text message (~15-20KB).
  1. Launch multiple instances via the Task tool in parallel (7 in my case), though the bug could also reproduce with isolated single agents.
  1. 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.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗