SubagentStop hook does not fire when team agents terminate via shutdown protocol

Status Fixed / completed
Maintainer reply ✓ Yes — ashwin-ant
Activity 12 comments · opened Apr 8, 2026 · closed Apr 18, 2026
💡 Likely answer: A maintainer (ashwin-ant, collaborator) responded on this thread — see the highlighted reply below.

Summary

When a team agent (teammate) terminates via the shutdown_requestshutdown_approved protocol, the SubagentStop hook does not fire. This leaves external systems that track agent lifecycle via hooks with phantom agent counts that never decrement.

Reproduction

  1. Enable agent teams: CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
  2. Register a SubagentStop hook that logs to a JSONL file (or any observable side effect)
  3. Create a team and spawn teammates
  4. Send shutdown_request to teammates
  5. Observe that shutdown_approved is received, teammate_terminated system message appears, but SubagentStop hook never fires

Evidence

JSONL event log showing 6 team agents with agent.start events and no matching agent.stop:

# Team 1: resilience-architect and test-architect
{"ts":"2026-04-08T01:54:13Z","event":"agent.start","agent_id":"aacd2d977d5b10367","agent_type":"resilience-architect"}
{"ts":"2026-04-08T01:54:26Z","event":"agent.start","agent_id":"ad43c61987f3d60bd","agent_type":"test-architect"}
# Both received shutdown_approved — no agent.stop events ever appeared

# Team 2: three review agents
{"ts":"2026-04-08T02:09:22Z","event":"agent.start","agent_id":"a712f607506a003a7","agent_type":"reuse-reviewer"}
{"ts":"2026-04-08T02:09:22Z","event":"agent.start","agent_id":"a912c5cfbfc5e9ae4","agent_type":"quality-reviewer"}
{"ts":"2026-04-08T02:09:23Z","event":"agent.start","agent_id":"ab21e0518d3daf119","agent_type":"efficiency-reviewer"}
# All three received shutdown_approved — no agent.stop events

Meanwhile, regular subagents (non-team) and team agents that go through idle→wake cycles (via SendMessage) DO correctly fire SubagentStop. The issue is specifically the shutdown_requestshutdown_approved termination path.

Expected behavior

SubagentStop should fire for every agent that triggered SubagentStart, regardless of how the agent terminates (normal completion, shutdown protocol, or error).

Impact

Any plugin or hook that tracks agent count via SubagentStart/SubagentStop pairs will show permanently inflated counts after team sessions. This affects resource management tools that gate behavior on active agent count.

Environment

  • Claude Code v2.1.94
  • macOS Darwin 25.3.0
  • CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1

Context

Discovered while building coolant, a resource management plugin that tracks agent lifecycle via hooks to drive a thermal monitoring dashboard. The breathing agent icons stay lit indefinitely after team agent shutdown because the stop events never arrive.

View original on GitHub ↗

12 Comments

github-actions[bot] · 4 months ago

Found 1 possible duplicate issue:

  1. https://github.com/anthropics/claude-code/issues/33049

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

todd-w-shaffer · 4 months ago

Duplicate of #27755 and #25147 — the missing SubagentStop on team agent shutdown is a subset of the broader issue where subagent lifecycle hooks don't fire on completion. Closing in favor of those.

Our workaround in coolant: agents that exceed a staleness threshold (3 min with no stop event) are visually dimmed in the dashboard rather than removed — orphaned but honest.

todd-w-shaffer · 4 months ago

Reopening — this is not a duplicate of #27755 or #25147. The cited issues describe different failure modes:

#25147 — Background agents bypass Stop hooks

  • Hook type: Stop (session-level), not SubagentStop
  • Trigger: run_in_background=true
  • Symptom: Hook never fires at all for background agents

#27755 — SubagentStart/SubagentStop unreliable for Task tool

  • Hook type: SubagentStart/SubagentStop via settings.json
  • Trigger: Task tool dispatches
  • Symptom: Intermittent — hooks sometimes fire, sometimes don't; agent_type sometimes empty

This issue (#44971) — SubagentStop not fired on team agent shutdown protocol

  • Hook type: SubagentStop (works perfectly for non-team agents)
  • Trigger: Team agent terminated via shutdown_requestshutdown_approved
  • Symptom: 100% reproducible — SubagentStop NEVER fires for this specific code path

Key distinction: Our SubagentStart/SubagentStop hooks fire reliably and symmetrically for every regular subagent (Agent tool). We have JSONL evidence of dozens of clean start/stop pairs in the same session. The failure is isolated to one specific termination path: the team agent shutdown protocol.

Evidence from a single session showing both working and broken hooks:

# Regular subagent — SubagentStop fires correctly ✓
{"ts":"…T02:08:24Z","event":"agent.start","agent_id":"af6ce16ba…","agent_type":"general-purpose"}
{"ts":"…T02:08:58Z","event":"agent.stop","agent_id":"af6ce16ba…","agent_type":"general-purpose"}

# Team agent — SubagentStart fires, SubagentStop never fires ✗
{"ts":"…T02:09:22Z","event":"agent.start","agent_id":"a712f6075…","agent_type":"reuse-reviewer"}
# ← shutdown_approved received, teammate_terminated system message appeared, NO agent.stop ever

# Same pattern for all 5 team agents in the session:
# a712f607… (reuse-reviewer)    — start only, no stop
# a912c5cf… (quality-reviewer)  — start only, no stop
# ab21e051… (efficiency-reviewer) — start only, no stop
# aacd2d97… (resilience-architect) — start only, no stop
# ad43c619… (test-architect)    — start only, no stop

This is a team-specific code path issue, not the general SubagentStop unreliability described in #27755.

todd-w-shaffer · 4 months ago

Additional finding: shutdown protocol bypass affects hook-driven state

The missing SubagentStop on the shutdown protocol path has a compounding effect beyond missed events. Any system that tracks agent count via SubagentStart/SubagentStop pairs — including Claude Code's own hook infrastructure — will accumulate phantom agents that never decrement.

In our testing, this caused SubagentStart hooks that read agent count to make incorrect decisions for the remainder of the session, since their count inputs were permanently inflated by terminated-but-untracked agents.

The distinguishing signal for debugging: in the same session, SubagentStop fires correctly for every Agent tool subagent that completes normally. It only fails for agents that terminate via shutdown_requestshutdown_approved. This points to a specific code path in the team agent shutdown handler that doesn't call the same hook dispatch as normal agent completion.

Minimal repro narrowed to one variable:

  1. Spawn agent A via Agent tool (no team) → SubagentStart fires, agent completes → SubagentStop fires ✓
  2. Spawn agent B via Agent tool with team_nameSubagentStart fires, send shutdown_request, receive shutdown_approvedSubagentStop never fires ✗

Same hook config, same session, same matcher (.*). The only variable is the termination path.

todd-w-shaffer · 4 months ago

Shameless plug — coolant is the thermal dashboard that surfaced this bug. The orphaned team agents were visible as breathing hexagons that never stopped. We've since added stale-agent dimming so orphaned dots fade to ~35% brightness after 3 minutes, making the missing SubagentStop events immediately obvious at a glance.

yurukusa · 4 months ago

Your root cause analysis is spot on — the shutdown protocol handler bypasses the hook dispatcher that normal agent completion uses.

Alternative workaround: PostToolUse hook on SendMessage

Since teammate_terminated system messages do appear (as you noted), and SendMessage is the tool used to communicate with team agents, you can use a PostToolUse hook on SendMessage to detect shutdown completion and synthetically handle cleanup:

#!/bin/bash
# team-agent-stop-synthetic.sh
# PostToolUse hook — detects team agent shutdown and triggers cleanup
# TRIGGER: PostToolUse
# MATCHER: SendMessage

INPUT=$(cat)
RESULT=$(echo "$INPUT" | jq -r '.tool_result // empty' 2>/dev/null)

# Check if the SendMessage response indicates shutdown
if echo "$RESULT" | grep -qi 'shutdown_approved\|teammate_terminated\|agent.*shut.*down'; then
  AGENT_ID=$(echo "$INPUT" | jq -r '.tool_input.to // empty' 2>/dev/null)
  TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)

  # Log synthetic stop event
  echo "{\"ts\":\"$TIMESTAMP\",\"event\":\"agent.stop.synthetic\",\"agent_id\":\"$AGENT_ID\",\"reason\":\"shutdown_protocol\"}" >> /tmp/agent-lifecycle.jsonl

  # Signal to your tracking system
  echo "Team agent $AGENT_ID terminated via shutdown protocol (synthetic stop)" >&2
fi

exit 0
{
  "hooks": {
    "PostToolUse": [{
      "matcher": "SendMessage",
      "hooks": [{"type": "command", "command": "bash ~/.claude/hooks/team-agent-stop-synthetic.sh"}]
    }]
  }
}

This catches the termination at the moment shutdown_approved comes back, rather than waiting for a staleness timeout. The tradeoff is that it depends on the response text containing recognizable shutdown keywords — your 3-minute staleness approach in coolant is more robust as a fallback.

Both approaches could complement each other: the hook for immediate detection, staleness dimming for any edge cases the hook misses.

morganl-ant · 4 months ago

@todd-w-shaffer I identified the root cause and have a fix merging. Should be deployed early next week.

todd-w-shaffer · 4 months ago
@todd-w-shaffer I identified the root cause and have a fix merging. Should be deployed early next week.

Thanks man!

chernistry · 4 months ago

ran into this same lifecycle tracking issue. when agents terminate via shutdown protocol instead of normal exit, any external monitoring that relies on stop hooks ends up with ghost agents that look alive forever.

we worked around it by adding a secondary heartbeat check - if an agent hasnt written to its heartbeat file in 120 seconds, consider it dead regardless of what the hook says. the hook is nice to have but you cant rely on it as the sole source of truth for agent lifecycle. belt and suspenders.

the deeper issue is that agent lifecycle should be tracked by the orchestrator observing process state, not by the agent self-reporting. dead agents cant report their own death.

ashwin-ant collaborator · 4 months ago

This was fixed in v2.1.101 — The SubagentStop hook now fires when team agents are terminated via the shutdown protocol, guaranteeing it pairs with SubagentStart. If you're still seeing this in the latest version, please comment with your version and repro and we'll reopen.

todd-w-shaffer · 4 months ago

Follow-up: fix verified, one adjacent issue found

Confirmed in 2.1.105: team agents fire clean per-turn SubagentStart /
SubagentStop brackets on turn end and on SendMessage wake. Tested
across parallel agents, team initial spawn, idle/wake cycles, and
multi-session concurrency:

| Behavior | Status |
|---|---|
| Parallel agent start/stop | Reliable |
| Team initial spawn lifecycle | Reliable |
| agent_type = teammate name | Reliable |
| Per-turn agent_id rotation | Reliable |
| Team idle/wake hooks | Reliable (per-turn brackets clean) |
| session_id consistency | Reliable |

One adjacent issue surfaced during follow-up testing — likely same
code-path family, separate from this fix. Detailed below.

(Separately filed #49671 for the team shutdown protocol — different
surface, not related to this fix.)

---

Adjacent bug: orphan SubagentStop on session idle cleanup

~6-11 minutes after the last agent stops in a session, CC fires a
phantom SubagentStop with:

  • Empty agent_type (zero-length string)
  • Populated agent_id that matches no prior SubagentStart
  • Populated session_id (correct session UUID)
  • One orphan per session per idle period (not one per agent)

The empty agent_type makes it impossible to attribute the stop; the
phantom agent_id creates an unpaired stop event that desyncs any
start/stop bookkeeping.

Reproduction (confirmed 2026-04-16)

Spawned 3 team agents in session 1cd8e43f. All completed within
seconds, producing clean start/stop pairs. Monitored the JSONL event
log continuously.

02:54:15Z  agent.start  type=probe-a  id=a881f0ae7e38  (paired)
02:54:17Z  agent.start  type=probe-b  id=a67d20a1434f  (paired)
02:54:18Z  agent.stop   type=probe-a  id=a881f0ae7e38  (paired)
02:54:18Z  agent.start  type=probe-c  id=a45c181e9574  (paired)
02:54:19Z  agent.stop   type=probe-b  id=a67d20a1434f  (paired)
02:54:29Z  agent.stop   type=probe-c  id=a45c181e9574  (paired)
03:00:54Z  agent.stop   type=(empty)  id=a155a5853d08  <<< ORPHAN (+6.4min)

No further orphan events appeared in 8 minutes of additional
monitoring. One orphan per session, regardless of agent count.

Reproduction steps for any Anthropic engineer

  1. Install a plugin with SubagentStart/SubagentStop hooks that log all

stdin fields to a JSONL file

  1. Spawn 1+ agents (team or parallel — both trigger it)
  2. Let agents complete normally
  3. Monitor the JSONL log for ~10 minutes
  4. Expect: one SubagentStop with empty agent_type, fresh agent_id

matching no prior start, at approximately +6-11 minutes

  1. Verify: total starts != total stops, delta matches orphan count

Does not require waiting hours.

Timer behavior

Across multiple sessions, the gap from the last real SubagentStop
to the orphan event clusters at 6-11 minutes (N=3: 6.4min, 7min,
11min). Exact timer value unknown; could be deterministic with jitter,
or a periodic sweep the agent happens to be caught by. An engineer
with codebase access should be able to identify it.

Two early samples showed 24h and 31h gaps, both explained by macOS
sleep suspending CC — the timer fires on wake.

18 orphan events observed during the full investigation, all with the
same shape (agent_type="", fresh agent_id, one per session per
idle period). Full JSONL available on request.

One sample event for schema reference:

{"ts":"2026-04-16T03:00:54Z","event":"agent.stop","session_id":"1cd8e43f-ddb0-4257-8b53-c55119fae276","agent_id":"a155a5853d08451b2","agent_type":"","agent_count":0}

Hypothesis (needs code-side verification)

The fresh agent_id suggests either (a) the cleanup path creates a
new agent context rather than closing an existing one, OR (b) the
cleanup path shares code with the turn-completion path but fails to
pass agent metadata (agent_id gets regenerated, agent_type gets
dropped). Both match the observed data. Someone with repo access can
tell which.

The turn-completion path the fix touched populates both fields
correctly. The session-idle cleanup path appears to be either a
separate code path or a shared one with a metadata-passing gap.

Ask

Either:

  • Populate agent_type on cleanup-path SubagentStop events (same

fields as the turn-completion path), OR

  • Don't fire SubagentStop on session idle cleanup at all — let the

session boundary be implicit, or fire a dedicated SessionEnd
event instead

---

Small feature requests enabled by the fix

Now that per-turn brackets work, these become the next gaps. Filing
inline rather than as separate issues since they're adjacent:

  1. reason field on SubagentStop — distinguish

turn_complete / terminated / session_cleanup. Currently all
stops look identical; consumers can't tell idle-in-mailbox from
permanently-gone.

  1. Document agent_type semantics — for team members it carries

the teammate's name (e.g., "alpha"), not the subagent class
(e.g., "general-purpose"). Discovered empirically — stable
contract, or implementation detail?

  1. Document session_id — consistent per-session and necessary

for any multi-session hook consumer. Not currently documented.

Happy to file these as separate issues if preferred.

github-actions[bot] · 4 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.