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 None cached
Activity 2 comments · opened Aug 16, 2026

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 ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗