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
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
SubagentStopfires 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.jsonwithenv.TMPDIRlogs "project-scoped settings can't set this key" on every start since 2.1.257. The docs still listenvas project-settable without naming the excluded keys.
Environment
- Claude Code 2.1.259
- Linux
- Hooks:
PreCompact,PostCompact,SessionStart(compact),SubagentStop, registered in projectsettings.json
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
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
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 —— and the shared fork helper that runs every internal one-turn query mints a fresh id for the forked context while copying no type:
FEis the helper behind the activity summarizer (forkLabel:"agent_summary",maxTurns:1,skipTranscript:!0), the away summary, andcompact/reactive-compact. WithskipTranscriptthe transcript writer is never opened (mt=P?void 0:ly(d)), so theagent_transcript_pathderived from that fresh id is a file nothing will ever write. The activity summarizer is asetTimeoutloop atJks=30000per running background agent ([AgentSummary] Timer fired for agent …), which is where the ~30 s cadence comes from.Measured on 2.1.269 (matcher-less
SubagentStophook loggingagent_id/agent_type/agent_transcript_path+test -e): 165 of 176 stops across four SDK-hosted sessions hadagent_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>.jsonlplusagent-<child>.meta.json("parentAgentId":"…","spawnDepth":2), and both stops arrived with a realagent_typeand an existing transcript. So the.meta.jsondiscriminator in the original report is the right one — real spawns have it, forks never do.Given the branch, the smallest fixes look like: pick
SubagentStopinNZonly whenagentTypeis set (or an explicit real-subagent flag from the Agent-tool spawn path), and/or forwardforkLabelasagent_type(internal:agent_summary,internal:compact), and omitagent_transcript_pathwheneverskipTranscriptis on. Related from the outside: #87065 (matcher bypass on the empty string) and #89555 (fresh-id orphan stops) are the same emit shape.Looks like
InstructionsLoadedandNotificationhooks _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 leastagent_idandagent_type.I'm currently working around a lack of this information by hijacking
PreToolUsehooks and praying they happen close enough to the event I _actually_ want to respond to.