Skip to content

Getting a notification every time a subagent finishes, not when the task is done #6929

Open

Description

@frasernicol

Where did this happen?

Desktop app (macOS)

Version

1.25.0

What happened?

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.

Logs, screenshots, or recordings

No response

Activity

  1. github-actions commented on Aug 28, 2026

    @github-actions
    Contributor

    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 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:
      SUBAGENT_ID=$(echo "$INPUT" | grep -oE '"agent_id"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"')
      [ -n "$SUBAGENT_ID" ] && exit 0
    • 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/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. 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's session_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:

    1. 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.
    2. 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 before debug_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.ts
    it("ignores a subagent Stop even when the payload is pretty-printed", () => {
      const script = renderNotifyHookScript();
      const proc = 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

    1. Move the subagent decision off grep and make it observable. Emit the detected agent_id/hook_event_name into debug_log before 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.
    2. 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).
    3. 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.
    4. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions