SubagentStop matcher is silently bypassed when agent_type is empty — agent-scoped hooks fire on every internal fork
What happens
A hooks.json SubagentStop group whose matcher names specific agent types also fires for internal forks — compaction passes, progress-caption generators, memory extraction — whose agent_type is the empty string.
The empty string is falsy, so the matcher filter is skipped wholesale rather than excluding the group. In the bundled hook gatherer (observed in 2.1.233):
let d = (a ? s.filter((x) => !x.matcher || aOS(a, x.matcher, l, c)) : s).flatMap(...)
where a is the SubagentStop match key (agent_type). When a is "", the ternary takes the unfiltered branch and every registered SubagentStop group runs — the opposite of the intended scoping.
Reproduction
- Register a
SubagentStopgroup with a matcher naming real agent types, e.g."matcher": "quick_fix$|tdd_developer_easy$", and a hook that appends its stdin payload to a file. - Run a session long enough to spawn background subagents (progress captions tick roughly every 30s per running background agent) or to trigger compaction.
- The capture file fills with payloads whose
agent_typeis"". Confirm the matcher genuinely cannot match them —re.search(r"quick_fix$|tdd_developer_easy$", "")isNonein Python — yet the hook was dispatched anyway.
Measured on one session: 129 dispatches of a matcher-scoped hook, 115 with agent_type == "".
Why the matcher is the only lever
if conditions are not an alternative. The if evaluator is built only for PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequest / PermissionDenied; for any other event it returns undefined, and the caller then drops the hook from the run set entirely:
Hook if condition "<expr>" cannot be evaluated for non-tool event SubagentStop
So adding an if to scope a SubagentStop hook silently disables it. matcher is the only agent-scoping mechanism available for this event, and it does not work for the case that matters most.
Declaring the hook in an agent's own frontmatter does escape this (it registers under the agent's id, which no internal fork carries), but frontmatter hooks are dropped unconditionally for plugin-supplied agents — so plugin authors have no escape hatch at all.
Impact
We scope an LLM-backed type: prompt judge to three agent types. Because the matcher is bypassed, it ran on 115 internal forks in a single session. Around 21 of those hit the 100,000-token transcript-truncation budget, since the caption fork's message list embeds the parent agent's transcript — on the order of $2 per session of pure waste at Haiku rates.
The correctness cost is worse than the money. A judge that returns a blocking verdict on one of these sends a healthy, still-running agent back around its loop over a progress-caption fragment it never meant as a report, up to the block cap.
Expected
A falsy match key should exclude matcher-bearing groups rather than include them — s.filter((x) => !x.matcher) — so a group that opted into scoping never fires on an unscoped event.
Alternatively, internal forks should not emit SubagentStop at all. That was reported in #27423 and #59719, both closed not_planned; either fix resolves this one, but this specific consequence — that scoping silently inverts — does not appear to have been raised before.
3 Comments
Confirming on 2.1.233 (macOS). All minified names below are from that build.
Root cause
StopandSubagentStopshare one emitter,NDe(dispatch table:Stop:NDe,SubagentStop:NDe). The event type is chosen by a single ternary on the agent id:and the payload is built in the same expression:
So
SubagentStopmeans "this context has an id", not "this context was dispatched as a subagent". Internal forks mint their own id, so they qualify. They carry no agent type, soa??""produces the empty string directly. Andagent_transcript_pathis derived from the id with no existence check:The summarizer fork sets
skipTranscript:!0, so its path is never written, and no promised path from any class I observed existed on disk. There are 8 fork labels in the bundle:agent_summary,auto_dream,away_summary,compact,extract_memories,prompt_suggestion,rename,side_question. Every one of them can reach this emitter, which is why the payloads are indistinguishable across the different internal features people have reported.The three
NDecall sites all passagentTypecorrectly. This is not a caller forgetting an argument. The fork context has no type, because nothing ever assigned it one.Evidence
Measured on one 626-second
Exploresubagent, ida87…773d:SubagentStopevents during its run, each with a distinct freshly-minted agent id. None match that id.agent_transcript_pathfiles exist on disk.SubagentStartevents for any of the 17.31,31,31,32,31,32,31,32,31,31,31,31,32,85,32,31: mean 34.7s, or 31.3s excluding the single 85s outlier. That matchesvar fXS=30000plus generation latency, since the summarizer re-arms in thefinallyof the fork rather than on a fixed interval (function g(){if(d)return;u=setTimeout(h,s)}).So identity is minted at fork time, while type, the
SubagentStartevent, and the transcript are not. Every downstream symptom follows from that asymmetry.Why the matcher fix is not sufficient
The matcher-bypass diagnosis is correct, but the empty match key is produced two lines above it, in the emitter. Fixing the ternary in the gatherer stops matcher-scoped hooks from firing on forks; it does not stop unscoped hooks from receiving events for agents that never existed, and it leaves #27423/#59719 unresolved.
Recommendations, in order of how much they fix
SubagentStopfor a context that never emittedSubagentStart. Discriminate on "was dispatched as a subagent", not on "has an id". This resolves this issue and both closed predecessors at once.fork_labelfield on the payload). The 8 labels already exist internally; surfacing one would make every consumer's filter trivial and correct.s.filter((x)=>!x.matcher)on a falsy key). Correct regardless, and cheap, but it is a containment fix rather than a root-cause one.agent_transcript_pathforskipTranscriptforks. Emitting a path that is guaranteed absent is what forces consumers into a disk probe to tell dispatched agents from forks.Workaround today
ifconditions cannot scope this event either: the evaluator handles only tool events, returns undefined otherwise, and the caller then drops the hook from the run set entirely. Withmatcherbypassed andifunusable, the only working configuration is an unscopedSubagentStophook that self-filters on all three of:agent_typeis emptyagent_transcript_pathdoes not exist on diskagent_idowns no entry in a cache written by aSubagentStarthookCondition 3 is the load-bearing one. The first two check the payload's own contents; the third checks lifecycle, and given the root cause above it is the only one that cannot be wrong. With this live, the 626s agent logged exactly one Start and one Stop while all 17 forks were routed away, and no dispatched agent was dropped.
Filtering on empty
agent_typealone is riskier than it looks, for the same reason.CLAUDE_CODE_DISABLE_AGENT_VIEWis not an off switch for this. Its consumers (s3s(),fAr(),LU()) gate the agents view UI, the visible agent list, andclaude daemon run, and that call graph never reachesenableSummarization.Context
#27423 and #59719 report the same defect and were closed by the stale bot rather than triaged; #27423 and #27755 are now locked. Between them they document a 34.7% orphan rate over 30 days and a 42% partial-trace rate across 370 traces, on both macOS and Windows, from 2.1.144 through 2.1.233.
Reproduced on v2.1.233 (Linux).
Setup: a
SubagentStophook group in user settings with"matcher": "quick_fix$|tdd_developer_easy$"and a hook that appends its stdin to a file. Neither pattern can match an empty string.In one interactive session with a single backgrounded Explore agent running for a few minutes, the hook was dispatched 8 times — every payload had
"agent_type": ""(internal system forks, not user-visible subagents). As a control, letting a real Explore subagent finish produced zero dispatches — the matcher filters correctly wheneveragent_typeis a non-empty string, so the bypass is specific to the empty value: instead of excluding matcher-scoped groups, an emptyagent_typecauses matcher filtering to be skipped entirely and every registered group to fire.The docs describe the
SubagentStopmatcher as matching against agent type (the same values asSubagentStart), with no mention that unscoped internal events can flow through a scoped matcher, so this looks like a genuine scoping inversion rather than intended behavior. Still present as of the latest release per the changelog.🤖 Generated with Claude Code
Still present on v2.1.252 (Windows 11). Adding longitudinal volume data and a client-side discriminator I haven't seen raised in the thread.
The matcher-less case has no lever at all
The report establishes that
matcheris the only agent-scoping mechanism for this event and that anifcondition silently disables the hook. Both framings assume a group that wants scoping. Worth naming the population one step past that: aSubagentStopgroup registered with no matcher, which is what a notification/TTS-style hook wants — it intends to announce every real sub-agent completion, so it has nothing to scope in the first place.For that group the report's remedy does not apply. Fixing the falsy-key filter restores correct behaviour for matcher-bearing groups; a group with no matcher still receives every internal fork, because it never opted into scoping. (#87035 separately notes
ifconditions are coupled to tool names, which is the same reason they are unavailable here.)The one remaining candidate lever does not work either.
stop_hook_activeis present butfalseon every internal fork — 35/35 in the capture below. It is not that the flag is broken: across everySubagentStopdispatch this hook has logged in ~3.5 months, 3,018 in total, it wastrueexactly twice (0.066%). It works, and it is simply orthogonal to this population — each fork is minted with a freshagent_idand fires exactly once, and a context that fires once can never set a re-fire flag. Anyone reaching for it to quiet a noisySubagentStophook will find it changes essentially nothing.The user-visible result is an audio notification firing continuously during agent work instead of at completion. Flagging it because the two candidate fixes differ: excluding matcher-bearing groups on a falsy key fixes the reported case but not this one, whereas not emitting
SubagentStopfor internal forks at all fixes both.Measured payload shape
Captured with a temporary raw-payload probe inside the hook, over ~4 minutes with two real background agents running:
| | internal forks | real sub-agents |
|---|---|---|
|
agent_type(n=35) |""— 32/32 | populated — 3/3 ||
agent_transcript_pathresolves to a file that exists (n=21) | 0 / 19 | 2 / 2 ||
stop_hook_active(n=35) |false|false||
agent_id(n=35) | 35 distinct ids across 35 fires | — |(The two smaller samples are because the probe was extended mid-capture; the
agent_typeandagent_idrows cover all 35 dispatches.)agent_transcript_pathis a usable discriminator today. The decompiled emitter posted above shows it derived from the id (agent_transcript_path: oA(o)). For internal forks that path is synthesised but never written — the file does not exist on disk.The converse holds at a much larger sample. Checking every real sub-agent recorded on this machine over a 14-day window — each
subagents/agent-<id>.meta.jsonsidecar against its siblingagent-<id>.jsonl— the transcript was present for 127 / 127, none of them empty (smallest 24 KB). So:agent_transcript_pathnames a file that does not exist (0/19 observed)A hook that cannot use the matcher can therefore fall back to an existence check on that path, at the cost of one stat per fire. Posting it because it is the only lever I found that works for a matcher-less group — it is a workaround, not a substitute for the fix, and it depends on an internal path convention that could change.
last_assistant_messageidentifies what the forks are. On the empty-agent_typeevents this field holds the short progress-caption string that the UI renders beneath a running agent: gerund-led phrases, 33–59 characters across all 19 captured. On the real sub-agent events it holds the agent's actual final report, hundreds of characters. That is direct confirmation that the caption generator is the dominant fork source, rather than an inference from timing.Longitudinal volume
Existing reports in this family are single-session (the reproduction above is 8 dispatches). Over a 14-day window on one machine:
SubagentStopdispatches delivered to a no-matcher hook: 2,218The 127 is counted from
subagents/agent-*.meta.jsonsidecars. #59719 documents that some genuinely-dispatched sub-agents never get a sidecar, so treat it as a floor — the ratio is if anything understated.Corroborated by a second, independently-authored plugin on the same machine that also registers
SubagentStop: 1,964 fires over the same window. Same order of volume through a completely different hook, so this is not a defect in either plugin.Cross-session bleed
Hooks registered in user settings are global, so fires arrive carrying the
cwdof whichever project's session produced them. With two sessions open in different projects, 12 of 16 sampled fires originated in the session the user was not interacting with. For a notification hook this means one project's internal forks emit audio into a different project's session, which makes the noise hard to attribute from the user's side.The published docs currently promise two things this population violates
Worth fixing alongside the behaviour, since both read as guarantees today:
Neither holds when
agent_typeis"", and nothing on the page mentions that it can be. The example payload shows"agent_type": "Explore", so an author has no reason to expect the empty case.For internal forks that file is never written, so the field names a path that does not exist. (That is what makes it a usable discriminator, but it is currently an undocumented accident rather than a contract.)
A sentence stating that
agent_typemay be empty, and that matcher filtering does not apply in that case, would at least stop the next hook author from having to measure it.Expected
Endorsing the fix the report already proposes: a falsy match key should exclude matcher-bearing groups rather than include them. Additionally, if internal forks did not emit
SubagentStopat all, both the matcher-scoped and the matcher-less cases resolve together — the matcher-less one has no other remedy.