Hooks: PreCompact/PostCompact/SessionStart(compact) fire for a subagent's compaction without agent fields; SubagentStop fires for internal summarizer calls with a never-created agent_transcript_path

Status Open
Reported on v2.1.259
Maintainer reply None cached
Activity 8 comments · opened Sep 3, 2026

Summary

Three related hook-payload gaps around subagent compaction and the harness's internal summarizer calls. Observed on Claude Code 2.1.259, Linux, with project hooks registered for PreCompact, PostCompact, SessionStart (matcher compact) and SubagentStop.

1. PreCompact and SessionStart(compact) fire for a background subagent's auto-compaction with the main loop's payload shape

When a background subagent (Agent tool) auto-compacts, the PreCompact hook and the SessionStart hook with matcher: compact both run. The payload carries the parent session's session_id, transcript_path and cwd, and nothing that identifies the subagent: no agent_id, no agent_type, no agent_transcript_path, no flag that says the compaction is a subagent's. A hook cannot tell a subagent's compaction from the main loop's without parsing the transcript tail itself.

Consequence: a PreCompact hook that checkpoints state, or holds a compaction with {"decision":"block"}, applies to every subagent's compaction as well. A SessionStart(compact) hook that re-injects resume state injects the main loop's state into the subagent. The workaround is reading the transcript tail for isSidechain records, which is a heuristic.

Ask: carry agent_id, agent_type and agent_transcript_path on PreCompact, PostCompact and SessionStart(compact) when the compaction belongs to a subagent, matching what SubagentStop already carries. A boolean is_subagent would also do.

2. PostCompact fires for the subagent compaction with the same gap

PostCompact fires roughly 35 ms after SessionStart(compact) for a subagent's compaction, with the parent's fields plus compact_summary, and no agent identity. Same ask as above.

3. SubagentStop fires for internal model calls that are not subagents

The activity summarizer (the short progress strings in the task display) and the compaction summarizer (the <analysis> output) each fire SubagentStop with an empty agent_type, a fresh agent_id, and an agent_transcript_path that is never created on disk. In one long session, 342 of the 381 distinct agent_transcript_path values a SubagentStop hook recorded were such phantoms. Only 39 were real dispatches with a transcript file.

Consequence: a SubagentStop count is not a dispatch count, and any hook that opens agent_transcript_path reports hundreds of missing files. The workaround is discriminating on agent_type == "" and on the presence of the agent-<id>.meta.json sibling, which only real subagent transcripts have.

Ask: either do not fire SubagentStop for internal summarizer calls, or mark them (for example internal: true, or agent_type: "internal:activity" / "internal:compaction"), and do not populate agent_transcript_path with a path that will never exist.

4. Minor

  • SubagentStop fires before the subagent transcript's final record is appended, so a hook that reads the file at stop time sees an incomplete transcript. A flag on the payload once the file is final, or a post-stop event, would let hooks stop polling.
  • A project-scope settings.json with env.TMPDIR logs "project-scoped settings can't set this key" on every start since 2.1.257. The docs still list env as project-settable without naming the excluded keys.

Environment

  • Claude Code 2.1.259
  • Linux
  • Hooks: PreCompact, PostCompact, SessionStart (compact), SubagentStop, registered in project settings.json

View original on GitHub ↗

3 Comments

samvallad33 · 1 month ago

Subagent compact fires SessionStart without agent fields and injects the main loop's state into the child. I keep resume state scoped in Vestige. Parent standing orders do not belong in the subagent.

https://github.com/samvallad33/vestige

0xdhx · 1 month ago

Adding the emit-site mechanism for §3, read from the 2.1.269 bundle, plus confirmation it is still live there. Symbols are minified names from the installed native binary, so they are for orientation, not a permalink.

Why every internal fork produces a SubagentStop: the stop-hook dispatcher decides Stop vs SubagentStop on whether the context carries an agent id at all —

async function*NZ(e,n,r=Cp,s=!1,d,m,_,E,A="turn_end"){let P=d?"SubagentStop":"Stop"; …
  me=d?{...re,hook_event_name:"SubagentStop",stop_hook_active:s,agent_id:d,agent_transcript_path:yp(d),agent_type:E??"",…}

— and the shared fork helper that runs every internal one-turn query mints a fresh id for the forked context while copying no type:

// HTn(parentCtx, overrides) → forked toolUseContext
agentId:n?.agentId??ly(),  agentType:n?.agentType,   // FE never passes agentType → undefined → ""

FE is the helper behind the activity summarizer (forkLabel:"agent_summary", maxTurns:1, skipTranscript:!0), the away summary, and compact / reactive-compact. With skipTranscript the transcript writer is never opened (mt=P?void 0:ly(d)), so the agent_transcript_path derived from that fresh id is a file nothing will ever write. The activity summarizer is a setTimeout loop at Jks=30000 per running background agent ([AgentSummary] Timer fired for agent …), which is where the ~30 s cadence comes from.

Measured on 2.1.269 (matcher-less SubagentStop hook logging agent_id / agent_type / agent_transcript_path + test -e): 165 of 176 stops across four SDK-hosted sessions had agent_type: "" and a never-created path; in one session 50 of its 53 phantoms fired inside a running background agent's lifetime at ~31 s intervals (17:54:00, 17:54:31, 17:55:03, 17:55:34, …), the other 3 coincided with away-summary events.

Control — nested Agent-tool spawns are unaffected, in case anyone reads the phantoms as depth-2 spawns: a background agent that spawns one child produced subagents/agent-<child>.jsonl plus agent-<child>.meta.json ("parentAgentId":"…","spawnDepth":2), and both stops arrived with a real agent_type and an existing transcript. So the .meta.json discriminator in the original report is the right one — real spawns have it, forks never do.

Given the branch, the smallest fixes look like: pick SubagentStop in NZ only when agentType is set (or an explicit real-subagent flag from the Agent-tool spawn path), and/or forward forkLabel as agent_type (internal:agent_summary, internal:compact), and omit agent_transcript_path whenever skipTranscript is on. Related from the outside: #87065 (matcher bypass on the empty string) and #89555 (fresh-id orphan stops) are the same emit shape.

pierzchalski · 28 days ago

Looks like InstructionsLoaded and Notification hooks _also_ don't get subagent identity information. #16424 (closed as complete) was meant to cover this sort of thing, should it be reopened? This comment sounds like _every_ hook event from a subagent was intended to have at least agent_id and agent_type.

I'm currently working around a lack of this information by hijacking PreToolUse hooks and praying they happen close enough to the event I _actually_ want to respond to.

Showing cached comments. Read the full discussion on GitHub ↗