SubagentStop matcher is silently bypassed when agent_type is empty — agent-scoped hooks fire on every internal fork

Status Open
Reported on v2.1.233
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Aug 16, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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

  1. Register a SubagentStop group 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.
  2. Run a session long enough to spawn background subagents (progress captions tick roughly every 30s per running background agent) or to trigger compaction.
  3. The capture file fills with payloads whose agent_type is "". Confirm the matcher genuinely cannot match them — re.search(r"quick_fix$|tdd_developer_easy$", "") is None in 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.

View original on GitHub ↗

3 Comments

neeohh · 1 month ago

Confirming on 2.1.233 (macOS). All minified names below are from that build.

Root cause

Stop and SubagentStop share one emitter, NDe (dispatch table: Stop:NDe,SubagentStop:NDe). The event type is chosen by a single ternary on the agent id:

async function*NDe(e,t,r=My,n=!1,o,i,s,a){let l=o?"SubagentStop":"Stop";

and the payload is built in the same expression:

g=o?{...h,hook_event_name:"SubagentStop",stop_hook_active:n,agent_id:o,agent_transcript_path:oA(o),agent_type:a??"",last_assistant_message:f,...m}:{...h,hook_event_name:"Stop",...}

So SubagentStop means "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, so a??"" produces the empty string directly. And agent_transcript_path is derived from the id with no existence check:

function oA(e) {
  let t = UZo.of(Os().host).agentTranscriptSubdirs.get(e),
    r = t ? r4e.join(OMn(), t) : OMn();
  return r4e.join(r, `agent-${e}.jsonl`);
}

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 NDe call sites all pass agentType correctly. 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 Explore subagent, id a87…773d:

  • 17 phantom SubagentStop events during its run, each with a distinct freshly-minted agent id. None match that id.
  • 0 of those 17 promised agent_transcript_path files exist on disk.
  • 0 SubagentStart events for any of the 17.
  • Consecutive gaps 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 matches var fXS=30000 plus generation latency, since the summarizer re-arms in the finally of the fork rather than on a fixed interval (function g(){if(d)return;u=setTimeout(h,s)}).
  • Separately, with zero subagents dispatched, 4 more arrived at turn boundaries across 11m38s. Same signature, different cadence. That is the class @austinhrin reported on #59719.

So identity is minted at fork time, while type, the SubagentStart event, 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

  1. Do not emit SubagentStop for a context that never emitted SubagentStart. Discriminate on "was dispatched as a subagent", not on "has an id". This resolves this issue and both closed predecessors at once.
  2. If forks must keep emitting, give them their own event (or a fork_label field on the payload). The 8 labels already exist internally; surfacing one would make every consumer's filter trivial and correct.
  3. Fix the matcher ternary as the issue proposes (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.
  4. Stop deriving agent_transcript_path for skipTranscript forks. Emitting a path that is guaranteed absent is what forces consumers into a disk probe to tell dispatched agents from forks.

Workaround today

if conditions 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. With matcher bypassed and if unusable, the only working configuration is an unscoped SubagentStop hook that self-filters on all three of:

  1. agent_type is empty
  2. the payload's agent_transcript_path does not exist on disk
  3. the agent_id owns no entry in a cache written by a SubagentStart hook

Condition 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_type alone is riskier than it looks, for the same reason.

CLAUDE_CODE_DISABLE_AGENT_VIEW is not an off switch for this. Its consumers (s3s(), fAr(), LU()) gate the agents view UI, the visible agent list, and claude daemon run, and that call graph never reaches enableSummarization.

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.

bcherny collaborator · 1 month ago

Reproduced on v2.1.233 (Linux).

Setup: a SubagentStop hook 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 whenever agent_type is a non-empty string, so the bypass is specific to the empty value: instead of excluding matcher-scoped groups, an empty agent_type causes matcher filtering to be skipped entirely and every registered group to fire.

The docs describe the SubagentStop matcher as matching against agent type (the same values as SubagentStart), 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

austinhrin · 1 month ago

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 matcher is the only agent-scoping mechanism for this event and that an if condition silently disables the hook. Both framings assume a group that wants scoping. Worth naming the population one step past that: a SubagentStop group 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 if conditions 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_active is present but false on every internal fork — 35/35 in the capture below. It is not that the flag is broken: across every SubagentStop dispatch this hook has logged in ~3.5 months, 3,018 in total, it was true exactly twice (0.066%). It works, and it is simply orthogonal to this population — each fork is minted with a fresh agent_id and fires exactly once, and a context that fires once can never set a re-fire flag. Anyone reaching for it to quiet a noisy SubagentStop hook 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 SubagentStop for 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_path resolves 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_type and agent_id rows cover all 35 dispatches.)

agent_transcript_path is 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.json sidecar against its sibling agent-<id>.jsonl — the transcript was present for 127 / 127, none of them empty (smallest 24 KB). So:

  • internal fork → agent_transcript_path names a file that does not exist (0/19 observed)
  • real sub-agent → the file exists and is non-empty (127/127)

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_message identifies what the forks are. On the empty-agent_type events 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:

  • SubagentStop dispatches delivered to a no-matcher hook: 2,218
  • Real sub-agents actually dispatched in the same window: 127
  • Ratio: ~17.5x
  • Three separate days had zero real sub-agents dispatched and still produced 63, 69 and 21 dispatches.

The 127 is counted from subagents/agent-*.meta.json sidecars. #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 cwd of 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:

SubagentStop — Runs when a Claude Code subagent has finished responding. Matches on agent type, same values as SubagentStart.
The agent_type field is the value used for matcher filtering.

Neither holds when agent_type is "", 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.

agent_transcript_path is the subagent's own transcript stored in a nested subagents/ folder.

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_type may 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 SubagentStop at all, both the matcher-scoped and the matcher-less cases resolve together — the matcher-less one has no other remedy.