[BUG] Stop hook background_tasks includes ambient tasks (e.g. artifact monitor_ws watchers), so hook consumers think the session is still busy

Status Open
Reported on v2.1.286
Maintainer reply None cached
Activity 0 comments · opened Oct 1, 2026

Summary

The Stop / SubagentStop hook payload fills background_tasks with every task that is running or pending (and not foreground). That includes ambient tasks, which Claude Code itself excludes from its activity indicators. The payload schema has no ambient field, so a hook cannot tell them apart.

The most common case is the monitor_ws live-update watcher that is armed automatically when the session publishes a claude.ai Artifact. Once a session has published one artifact, background_tasks is never empty again for the rest of that watcher's life. Integrations that read the field to decide "turn finished, nothing pending" therefore report the session as still busy after every turn.

Environment

  • Claude Code 2.1.286 (macOS, native install)
  • Seen through the cmux terminal (0.64.24), which reads background_tasks at Stop. The bug is not specific to cmux.

Steps to reproduce

  1. Register a Stop hook that appends its stdin to a file:

``json
{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "cat >> /tmp/stop-payloads.jsonl" } ] } ] } }
``

  1. Start a session on an account with Artifacts enabled. Ask Claude to publish a small HTML page with the Artifact tool.
  2. Let the turn end. Send one or two more unrelated prompts, and let each turn end.
  3. Look at background_tasks in the captured payloads.

Expected: [] once nothing is actually running, the same way Claude Code's own UI shows the session as idle.

Actual (expected from the code below, not captured raw; see the observed/inferred section): a monitor entry with status: "running" stays in every later payload while the watcher lives, for example:

"background_tasks": [ { "id": "<task id>", "type": "monitor", "status": "running", "description": "<artifact live updates>" } ]

Where it comes from (2.1.286 bundle, minified names)

  • The Stop payload is built with background_tasks: KFt(taskRegistry.all()).
  • KFt keeps every task for which Bm(task) is true. Bm returns true when status is running or pending and the task is not foreground. It does not check ambient. monitor_ws gets no extra fields (case "monitor_ws": break).
  • Ite(task) returns task.skipTranscript === true || (task.type === "monitor_ws" && task.ambient === true). The description attached to ambient elsewhere in the SDK schema says: "True for tasks that are not activity (every skip_transcript task, plus every live-update watcher, requested or auto-started); hosts should exclude them from activity indicators."
  • Claude Code applies that rule to itself. The idle/idle_background computation (Iv) filters with !Ite(n), and the SDK system/background_tasks_changed event sets ambient: true on these entries.
  • The hook schema for background_tasks items (id, type, status, description, command, agent_type, server, tool, name) has no ambient field.

So the same task is "not activity" for the SDK stream and the CLI's own status, but it counts as "in-flight background work" for hooks.

Observed vs inferred

  • Observed in the 2.1.286 bundle: the KFt/Bm filter, the Ite predicate, the ambient description, the Iv exclusion, and the hook item schema without ambient.
  • Observed on one machine through cmux's state file, which records whether background_tasks had a running entry at the last Stop: the 4 sessions flagged that way (3 stuck after their last turn, 1 active) had all published artifacts. The ~60 sessions that correctly went idle had not published any recently. Two of them had published artifacts about two weeks earlier.
  • Inferred, not captured: that the running entry in those payloads was the artifact monitor_ws watcher. The raw Stop payloads were not logged (cmux redacts them), so the exact entry was not seen.

Request

Either of these would fix it:

  1. Leave ambient tasks (Ite(task) === true) out of background_tasks in the Stop / SubagentStop payloads, consistent with the field's description ("in-flight background work"), or
  2. Add ambient: true to each such entry, as background_tasks_changed already does, and document it so hooks can filter.

Option 2 keeps all the information and lets each hook decide. Option 1 is closer to the field's documented purpose ("lets hooks distinguish 'session is done' from 'session is paused waiting for background work to wake it'"), and to how Claude Code reports its own idle state.

Related: #85955 (the same problem for idle teammates, closed as not planned), #89019 / #89308 (the auto-armed artifact watcher has no handle and no opt-out).

View original on GitHub ↗