Skip to content

Claude Code SubagentStop is dropped: Entire correlates on tool_use_id, which the hook does not send #2215

Description

@mmanhertz

What happened?

What I did

Ran a normal Claude Code session with Entire enabled and the Claude Code hooks
installed (entire enable, entire agent add claude-code). The session dispatched
background subagents, and used skills that fan out into further subagents.

What I expected

Per https://docs.entire.io/integrations/claude-code:

"Claude Code exposes task lifecycle events and separate subagent transcripts. Entire
records each task as a nested session and attributes its files and tokens to the parent
session."

So: one record per subagent, with its files and tokens attributed to the parent.

What actually happened

Entire detects the subagents but discards almost all of them. entire session list shows
one session. No nested session, no task record, and no per-subagent token figure is
reachable from any CLI command.

The dropped events log this pair:

{"level":"WARN","msg":"subagent-stop payload missing tool_use_id or session_id — structurally impossible for a well-formed payload","component":"agent.claudecode","agent":"claude-code","has_tool_use_id":false,"has_session_id":true}
{"level":"WARN","msg":"no in-flight marker for completed subagent — foreground dedup, a duplicate event, or a misintegrated agent setting Final without launch markers","component":"lifecycle","agent":"claude-code","tool_use_id":"","agent_id":"a1b2c3d4e5f60718"}

agent_id is present and well formed. It is written to the log and then thrown away.

Is it reproducible?

Every time. A deterministic, offline reproduction is in "Steps to reproduce" below. It
needs no Claude Code session — it drives the installed hooks with synthetic payloads.

Root cause

Claude Code's documented contract (https://code.claude.com/docs/en/hooks) lists
tool_use_id as a field of tool events only — PreToolUse, PostToolUse,
PostToolUseFailure. SubagentStop does not include it. It does include:

agent_id: Unique identifier for the subagent. Present only when the hook fires inside a
subagent call.
agent_type: Agent name (for example, "Explore" or "security-reviewer").

The parser expects it anywaycmd/entire/cli/agent/claudecode/types.go on main:

type subagentStopHookInputRaw struct {
	SessionID           string `json:"session_id"`
	TranscriptPath      string `json:"transcript_path"`
	AgentID             string `json:"agent_id"`
	AgentTranscriptPath string `json:"agent_transcript_path"`
	ToolUseID           string `json:"tool_use_id"`
}

cmd/entire/cli/agent/claudecode/lifecycle.go then calls the absence "structurally
impossible for a well-formed payload". Per the Claude Code spec the payload is well formed,
and the expectation is wrong.

Correlation is keyed on that field alonecmd/entire/cli/lifecycle.go,
handleSubagentStopFinal on main:

marker := state.FindTaskRecord(event.ToolUseID)
if marker == nil || !marker.CompletedAt.IsZero() {

event.SubagentID (the agent_id) is populated by the parser. It is used only for logging,
and as a fallback from the marker to the event — never as a lookup key.

That branch attributes the failure to "a misintegrated agent setting Final without launch
markers", so the condition currently reads as the agent's fault rather than as a missing
fallback.

Consequence: any subagent Entire did not observe launching is unrecoverable. That is every
subagent spawned by another subagent, and every subagent launched from inside a skill,
because no PreToolUse/PostToolUse Agent hook fires for those in the parent session.

The parser also carries an open tripwire — a Debug log of the raw payload's key names —
with the comment: "Removable once real-payload key sets have been observed and the parse
below is confirmed against them."
The reproduction prints that key set. See below.

Impact measured in a real session

One 27-turn Claude Code session using background subagents and fan-out skills. Counts only;
no transcript content.

Item Count
Distinct agent_id values seen in SubagentStop 34
Correlated by Entire 7
Never correlated — dropped 27
Dropped SubagentStop events in total 36
Task records written at most 2
Nested sessions in entire session list 0
Checkpoints with checkpoint_type other than session 0

Two further effects there:

  • Each of the 7 correlated subagents also fired a second, uncorrelated SubagentStop,
    producing a spurious warning for work already recorded.
  • 5 of the 7 correlated subagents were then skipped with
    "no file changes detected, skipping task record". A read-only research subagent
    therefore leaves no record at all — not even a token figure.

entire checkpoint explain <id> --json returns keys checkpoint_id, strategy, branch, checkpoints_count, files_touched, session_count, sessions. There is no tasks or
task_records key, and session_count is 1 for every checkpoint.

Suggested fix

  1. Correlate on agent_id when tool_use_id is empty. Claude Code always sends it and the
    parser already reads it.
  2. Create a task record on SubagentStop when no launch marker exists, instead of
    discarding the event. Deduplicate on agent_id so a second event for an already-recorded
    subagent is a no-op rather than a warning.
  3. Record subagents that changed no files. A read-only subagent still consumed tokens;
    dropping it makes the parent's totals unattributable.
  4. Remove or invert the "structurally impossible for a well-formed payload" tripwire. Per
    the Claude Code hooks spec, a SubagentStop without tool_use_id is the normal case.
  5. Either implement nested sessions as the docs promise, or correct the docs. PR feat(claude-code): capture background subagent work durably (SubagentStop + task records) #2032
    describes task records under the parent checkpoint, not nested sessions. The docs and
    the design do not agree, separately from this bug.

Steps to reproduce

Deterministic and offline. No Claude Code session required — this drives the installed
hooks directly with synthetic payloads. Every value is made up.

#!/usr/bin/env bash
set -euo pipefail

REPO=$(mktemp -d); cd "$REPO"
git init -q && git config user.email t@example.com && git config user.name Toy
echo "print('hi')" > app.py && git add -A && git commit -qm init

entire enable >/dev/null 2>&1 || true
entire agent add claude-code >/dev/null 2>&1 || true

SID="11111111-2222-3333-4444-555555555555"
TP="$REPO/transcript.jsonl"; : > "$TP"
AID="a1b2c3d4e5f60718"
TUID="toolu_01ReproControlAAAAAAAA"

hook() { echo "$2" | entire hooks claude-code "$1" >/dev/null 2>&1 || true; }

hook session-start '{"session_id":"'$SID'","transcript_path":"'$TP'","cwd":"'$REPO'","hook_event_name":"SessionStart","source":"startup"}'
hook user-prompt-submit '{"session_id":"'$SID'","transcript_path":"'$TP'","cwd":"'$REPO'","hook_event_name":"UserPromptSubmit","prompt":"repro"}'

# CASE A - payload exactly as Claude Code documents SubagentStop: agent_id, no tool_use_id.
hook subagent-stop '{"session_id":"'$SID'","transcript_path":"'$TP'","cwd":"'$REPO'","permission_mode":"default","hook_event_name":"SubagentStop","agent_id":"'$AID'","agent_type":"Explore","last_assistant_message":"done","stop_hook_active":false}'

# CASE B - control: same event, plus tool_use_id and a background launch marker.
hook pre-task '{"session_id":"'$SID'","transcript_path":"'$TP'","cwd":"'$REPO'","hook_event_name":"PreToolUse","tool_name":"Agent","tool_use_id":"'$TUID'","tool_input":{"description":"repro","prompt":"look","subagent_type":"Explore","run_in_background":true}}'
hook post-task '{"session_id":"'$SID'","transcript_path":"'$TP'","cwd":"'$REPO'","hook_event_name":"PostToolUse","tool_name":"Agent","tool_use_id":"'$TUID'","tool_input":{"description":"repro","prompt":"look","subagent_type":"Explore","run_in_background":true},"tool_response":{"agentId":"'$AID'"}}'
hook subagent-stop '{"session_id":"'$SID'","transcript_path":"'$TP'","cwd":"'$REPO'","hook_event_name":"SubagentStop","agent_id":"'$AID'","agent_type":"Explore","tool_use_id":"'$TUID'","last_assistant_message":"done"}'

grep -hE 'subagent started|subagent completed|no in-flight marker|missing tool_use_id' \
  .entire/logs/entire.log |
python3 -c "
import sys, json
for line in sys.stdin:
    d = json.loads(line)
    print('%-5s | %-58s | tool_use_id=%r' % (d['level'], d['msg'][:58], d.get('tool_use_id', '-')))
"

entire session list | tail -3

Observed output:

WARN  | subagent-stop payload missing tool_use_id or session_id —  | tool_use_id='-'
WARN  | no in-flight marker for completed subagent — foreground de | tool_use_id=''
INFO  | subagent started                                           | tool_use_id='toolu_01ReproControlAAAAAAAA'
INFO  | subagent completed                                         | tool_use_id='toolu_01ReproControlAAAAAAAA'

1 session

Case A (the spec-shaped payload) is dropped. Case B (same event with tool_use_id added and
a launch marker present) is recorded. The single differing field is tool_use_id.

Entire CLI version

Entire CLI 0.10.3 (Go go1.26.6, linux/amd64)

OS and architecture

Linux 7.0.0-30-generic x86_64 GNU/Linux

Agent

Claude Code

Terminal

No response

Logs / debug output

# Case A, the dropped event. From .entire/logs/entire.log in the reproduction repo.
{"level":"WARN","msg":"subagent-stop payload missing tool_use_id or session_id — structurally impossible for a well-formed payload","component":"agent.claudecode","agent":"claude-code","has_tool_use_id":false,"has_session_id":true}
{"level":"WARN","msg":"no in-flight marker for completed subagent — foreground dedup, a duplicate event, or a misintegrated agent setting Final without launch markers","component":"lifecycle","agent":"claude-code","tool_use_id":"","agent_id":"a1b2c3d4e5f60718"}

# The key set your own Debug tripwire asks for. Re-run the script with ENTIRE_LOG_LEVEL=debug.
{"level":"DEBUG","msg":"subagent-stop payload keys present","component":"agent.claudecode","agent":"claude-code","keys":["agent_id","agent_type","cwd","hook_event_name","last_assistant_message","permission_mode","session_id","stop_hook_active","transcript_path"]}

# No tool_use_id. No alternate spelling of it. Also no agent_transcript_path -- Entire
# already degrades to the agent-layout convention for that one, so it is a lesser problem,
# but it comes from the same unverified assumption.

# Case B, the control. Same event with tool_use_id present and a launch marker stored.
{"level":"INFO","msg":"subagent started","component":"lifecycle","agent":"claude-code","event":"SubagentStart","tool_use_id":"toolu_01ReproControlAAAAAAAA"}
{"level":"INFO","msg":"subagent completed","component":"lifecycle","agent":"claude-code","event":"SubagentEnd","tool_use_id":"toolu_01ReproControlAAAAAAAA","agent_id":"a1b2c3d4e5f60718"}

Additional context

Shell: bash. Not in a git worktree. Not over tmux or SSH. Git hooks and Claude Code hook
config both pass entire doctor.

Searched for duplicates. SubagentStop across open and closed issues and PRs in this
repo returns 21 results. None reports this for Claude Code.

Related, and why the in-flight PR does not cover this. PR #2067
("fix(cursor): correlate subagentStop without subagent_id via task description") addresses
the same class of defect and edits the shared handleLifecycleSubagentEnd path, so it looks
like it would help. It does not. It recovers an empty ToolUseID from a stored pre-task
state file via ResolvePreTaskToolUseID. Every subagent dropped here has no pre-task record
— that is exactly why it was dropped. Case B above, which does have one, already works
today. So #2067 fixes a case that is not broken here and cannot reach the ones that are. The
fallback this needs is agent_id.

Also related:

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions