You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Getting a notification every time a subagent finishes, not when the task is done #6929
I'm on Superset Desktop 1.25.0, macOS, using Claude Code.
When I run a task that uses subagents, I get a completion notification each time one of them finishes rather than once at the end. On one task that was three notifications in six seconds.
What I'd want is to be notified only when the agent is actually done, or when it needs input from me.
I know there was a fix for this (#6404), but I'm well past that version, and I've checked that I don't have any leftover hooks from an older install.
I've turned on debug logging and captured a run showing three separate completion events on the same session within a few seconds, with no input from me during that window. Happy to send the logs over, or grab more if there's something specific that would help.
Steps to reproduce
Steps to reproduce
Open a workspace in Superset and start a Claude Code session in it.
Give it a prompt that will fan out to several subagents — anything phrased as "use subagents to do X across these files" works. Something with three or four independent pieces is enough.
Switch away from Superset so notifications are visible.
Watch the notifications as it runs.
Expected: one notification when the whole task finishes, plus one any time it stops to ask me something.
Actual: a notification each time a subagent finishes, so several during a single task — in my case three within about six seconds, well before the task was done.
Superset registers a single global Claude Code hook (~/.claude/settings.json) that POSTs lifecycle events to host-service, and the "subagent" fix from #6404 lives entirely in the shell hook script: it drops any payload containing an agent_id field, on the assumption that Claude Code sets agent_id only inside a Task/subagent context. Every event that survives that filter and normalizes to anything other than Start/Attached/Detached produces a chime and a native notification, with no dedupe, debounce, or per-turn coalescing anywhere downstream. So "three completion notifications in six seconds on one session" is entirely plausible: either three events slipped past the one-line grep guard, or fewer real events got amplified by the fan-out. The report is credible and the version is current — apps/desktop/package.json:5 is 1.25.0, i.e. the user is running essentially this tree.
Affected code
packages/agent-setup/templates/notify-hook.template.sh:20-24 — the entire subagent suppression:
packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts:70-79 — CLAUDE_MANAGED_EVENTS: Stop, StopFailure, PermissionRequest (matcher *) are all notification-producing. SubagentStop is correctly not registered.
packages/host-service/src/trpc/router/notifications/notifications.ts:109-115 — broadcasts every accepted hook, no rate limiting or coalescing.
apps/desktop/src/renderer/.../V2NotificationController/lib/lifecycleEvents.ts:47-68 — the v2 chime/notification path. Only Start/Attached/Detached are skipped; there is no dedupe on terminalId or occurredAt.
apps/desktop/src/renderer/.../V2NotificationController/lib/notificationContent.ts:20-27 — everything except PermissionRequest is labelled Complete.
apps/desktop/src/main/lib/notifications/notification-manager.ts:57-101 — the v1 path; track() closes the prior toast for the same key but playSound() still runs per event.
Suspected root cause
Primary: the agent_id guard is failing open, and nothing records that it ran.
The strongest corroboration is Superset's own test fixture at packages/agent-setup/src/notify-hook.test.ts:110-116: a subagent hook payload carries agent_id alongside the parent'ssession_id ("main-session"). That is exactly the fingerprint the reporter describes — several completion events on the same session id, seconds apart. A main-loop Stop cannot legitimately repeat like that within one turn.
Two concrete ways the guard misses:
It is line-oriented grep. [[:space:]] never crosses a newline, so a pretty-printed or multi-line payload makes the agent_id match fail while "hook_event_name": "Stop" on its own line still parses fine — the guard fails open and the subagent event dispatches as a normal completion.
It keys on exactly one field name. If a Claude Code release moves or renames it (nested under an agent object, agent_type only, non-string value), the guard silently stops working. It is a string match against an upstream schema with no version pin and no test against a captured live payload.
Making this worse: the guard at line 24 runs beforedebug_log() is even defined (lines 94-99), so a suppressed subagent event leaves no trace, and a dispatched one records nothing about whether agent_id was absent or merely unparsed. The reporter's debug log will show three event=Stop lines with identical sessionId= and no way to attribute them. That diagnostic gap is worth closing regardless of the outcome here.
Secondary (also fits "well before the task was done"): StopFailure renders as "Complete".StopFailure is registered at agent-wrappers-claude-codex-opencode.ts:75 and, per the comment on line 68, fires while the session stays alive. It maps to Failed (map-event-type.ts:77-84), and notificationContent.ts:21 labels anything that isn't PermissionRequest as Complete. Transient API errors are common during parallel subagent fan-out, so a burst of StopFailure would present as several premature "Claude Code - Complete" toasts. This is distinguishable from the primary hypothesis purely from the log line: event=StopFailure vs event=Stop.
Amplification (not subagent-specific, but inflates the count): an event that takes the v1 Electron fallback in notify-hook.template.sh:167-208 is handled twice — once by the main-process NotificationManager (apps/desktop/src/main/windows/main.ts:228-234) and again after the renderer forwards it to the host at V2NotificationController.tsx:146-158, which broadcasts it back through HostNotificationSubscriber. Multiple open windows multiply this further, since V2NotificationController mounts per renderer.
I ruled out a few things: SubagentStop is not registered; PostToolUse (which is what fires in the main loop when a Task tool returns) maps to Start and is skipped; SessionStart/SessionEnd map to Attached/Detached and are skipped on the v2 path; and duplicate hook registration across installs is unlikely because the managed command is $SUPERSET_HOME_DIR-relative and deduped via isManagedNotifyCommand (agent-wrappers-common.ts:28-29, 77-86).
Reproduction
The cheapest repro needs no live Claude — drive the template directly, the way notify-hook.test.ts does. Two cases that should both be silent but are not:
// packages/agent-setup/src/notify-hook.test.tsit("ignores a subagent Stop even when the payload is pretty-printed",()=>{constscript=renderNotifyHookScript();constproc=Bun.spawnSync({cmd: ["bash","-c",script],env: hookEnv({}),stdin: Buffer.from(JSON.stringify({hook_event_name: "Stop",session_id: "main-session",agent_id: "a251e067cfdbabec7"},null,2,// <- only difference from the passing test at line 110),),stdout: "pipe",stderr: "pipe",});expect(proc.stderr.toString()).toBe("");// currently logs "event=Stop"});
For the secondary path, assert on presentation rather than the hook:
expect(getV2NativeNotificationContent({workspaceName: "ws",payload: {eventType: "Failed",terminalId: "t1",occurredAt: 0},}).title).not.toContain("Complete");// currently "Agent - Complete"
Live repro matches the reporter's steps. To make it diagnosable, run with SUPERSET_DEBUG_HOOKS=1 and SUPERSET_HOOK_DEBUG_LOG=/tmp/superset-agent-hooks.log, then compare the event=/sessionId= lines against the toast count.
Suggested direction
Move the subagent decision off grep and make it observable. Emit the detected agent_id/hook_event_name into debug_logbefore dropping (i.e. hoist the debug setup above the guard at line 24), so a failure to suppress is visible in exactly the log the reporter already captured. Longer term, send a subagent: true flag on the wire and drop it in notifications.ts, where it can be logged and tested against real payloads rather than parsed by a shell one-liner.
Broaden the guard to also bail on hook_event_name matching Subagent* and on the presence of agent_type, and make it newline-tolerant (tr -d '\n' on $INPUT before the greps, or parse once with a real JSON reader).
Stop labelling Failed as "Complete" in notificationContent.ts:20-27 — give Failed its own copy, matching the intent already documented in map-event-type.ts:8-10.
Add a coalescing window in lifecycleEvents.ts keyed on terminalId (drop a Stop/Failed arriving within ~2s of the last one for the same terminal). This is a cheap backstop for any future upstream schema drift, and would also absorb the v1-fallback double-delivery.
The reporter's offer of logs is worth taking up: the specific thing to ask for is the raw [notify-hook] lines for the three events (the event= and sessionId= fields) and, if they can get it, the raw hook stdin JSON for one subagent completion. That single payload settles hypothesis 1 vs 2 immediately.
Activity
github-actions commentedon Aug 28, 2026
Summary
Superset registers a single global Claude Code hook (
~/.claude/settings.json) that POSTs lifecycle events to host-service, and the "subagent" fix from #6404 lives entirely in the shell hook script: it drops any payload containing anagent_idfield, on the assumption that Claude Code setsagent_idonly inside a Task/subagent context. Every event that survives that filter and normalizes to anything other thanStart/Attached/Detachedproduces a chime and a native notification, with no dedupe, debounce, or per-turn coalescing anywhere downstream. So "three completion notifications in six seconds on one session" is entirely plausible: either three events slipped past the one-linegrepguard, or fewer real events got amplified by the fan-out. The report is credible and the version is current —apps/desktop/package.json:5is1.25.0, i.e. the user is running essentially this tree.Affected code
packages/agent-setup/templates/notify-hook.template.sh:20-24— the entire subagent suppression:packages/agent-setup/src/agent-wrappers-claude-codex-opencode.ts:70-79—CLAUDE_MANAGED_EVENTS:Stop,StopFailure,PermissionRequest(matcher*) are all notification-producing.SubagentStopis correctly not registered.packages/host-service/src/events/map-event-type.ts:77-95—StopFailure→Failed,Stop→Stop.packages/host-service/src/trpc/router/notifications/notifications.ts:109-115— broadcasts every accepted hook, no rate limiting or coalescing.apps/desktop/src/renderer/.../V2NotificationController/lib/lifecycleEvents.ts:47-68— the v2 chime/notification path. OnlyStart/Attached/Detachedare skipped; there is no dedupe onterminalIdoroccurredAt.apps/desktop/src/renderer/.../V2NotificationController/lib/notificationContent.ts:20-27— everything exceptPermissionRequestis labelledComplete.apps/desktop/src/main/lib/notifications/notification-manager.ts:57-101— the v1 path;track()closes the prior toast for the same key butplaySound()still runs per event.Suspected root cause
Primary: the
agent_idguard is failing open, and nothing records that it ran.The strongest corroboration is Superset's own test fixture at
packages/agent-setup/src/notify-hook.test.ts:110-116: a subagent hook payload carriesagent_idalongside the parent'ssession_id("main-session"). That is exactly the fingerprint the reporter describes — several completion events on the same session id, seconds apart. A main-loopStopcannot legitimately repeat like that within one turn.Two concrete ways the guard misses:
grep.[[:space:]]never crosses a newline, so a pretty-printed or multi-line payload makes theagent_idmatch fail while"hook_event_name": "Stop"on its own line still parses fine — the guard fails open and the subagent event dispatches as a normal completion.agentobject,agent_typeonly, non-string value), the guard silently stops working. It is a string match against an upstream schema with no version pin and no test against a captured live payload.Making this worse: the guard at line 24 runs before
debug_log()is even defined (lines 94-99), so a suppressed subagent event leaves no trace, and a dispatched one records nothing about whetheragent_idwas absent or merely unparsed. The reporter's debug log will show threeevent=Stoplines with identicalsessionId=and no way to attribute them. That diagnostic gap is worth closing regardless of the outcome here.Secondary (also fits "well before the task was done"):
StopFailurerenders as "Complete".StopFailureis registered atagent-wrappers-claude-codex-opencode.ts:75and, per the comment on line 68, fires while the session stays alive. It maps toFailed(map-event-type.ts:77-84), andnotificationContent.ts:21labels anything that isn'tPermissionRequestasComplete. Transient API errors are common during parallel subagent fan-out, so a burst ofStopFailurewould present as several premature "Claude Code - Complete" toasts. This is distinguishable from the primary hypothesis purely from the log line:event=StopFailurevsevent=Stop.Amplification (not subagent-specific, but inflates the count): an event that takes the v1 Electron fallback in
notify-hook.template.sh:167-208is handled twice — once by the main-processNotificationManager(apps/desktop/src/main/windows/main.ts:228-234) and again after the renderer forwards it to the host atV2NotificationController.tsx:146-158, which broadcasts it back throughHostNotificationSubscriber. Multiple open windows multiply this further, sinceV2NotificationControllermounts per renderer.I ruled out a few things:
SubagentStopis not registered;PostToolUse(which is what fires in the main loop when a Task tool returns) maps toStartand is skipped;SessionStart/SessionEndmap toAttached/Detachedand are skipped on the v2 path; and duplicate hook registration across installs is unlikely because the managed command is$SUPERSET_HOME_DIR-relative and deduped viaisManagedNotifyCommand(agent-wrappers-common.ts:28-29, 77-86).Reproduction
The cheapest repro needs no live Claude — drive the template directly, the way
notify-hook.test.tsdoes. Two cases that should both be silent but are not:For the secondary path, assert on presentation rather than the hook:
Live repro matches the reporter's steps. To make it diagnosable, run with
SUPERSET_DEBUG_HOOKS=1andSUPERSET_HOOK_DEBUG_LOG=/tmp/superset-agent-hooks.log, then compare theevent=/sessionId=lines against the toast count.Suggested direction
grepand make it observable. Emit the detectedagent_id/hook_event_nameintodebug_logbefore dropping (i.e. hoist the debug setup above the guard at line 24), so a failure to suppress is visible in exactly the log the reporter already captured. Longer term, send asubagent: trueflag on the wire and drop it innotifications.ts, where it can be logged and tested against real payloads rather than parsed by a shell one-liner.hook_event_namematchingSubagent*and on the presence ofagent_type, and make it newline-tolerant (tr -d '\n'on$INPUTbefore the greps, or parse once with a real JSON reader).Failedas "Complete" innotificationContent.ts:20-27— giveFailedits own copy, matching the intent already documented inmap-event-type.ts:8-10.lifecycleEvents.tskeyed onterminalId(drop aStop/Failedarriving within ~2s of the last one for the same terminal). This is a cheap backstop for any future upstream schema drift, and would also absorb the v1-fallback double-delivery.The reporter's offer of logs is worth taking up: the specific thing to ask for is the raw
[notify-hook]lines for the three events (theevent=andsessionId=fields) and, if they can get it, the raw hook stdin JSON for one subagent completion. That single payload settles hypothesis 1 vs 2 immediately.