[BUG] Stop hook background_tasks includes ambient tasks (e.g. artifact monitor_ws watchers), so hook consumers think the session is still busy
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_tasksatStop. The bug is not specific to cmux.
Steps to reproduce
- Register a
Stophook that appends its stdin to a file:
``json``
{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "cat >> /tmp/stop-payloads.jsonl" } ] } ] } }
- Start a session on an account with Artifacts enabled. Ask Claude to publish a small HTML page with the
Artifacttool. - Let the turn end. Send one or two more unrelated prompts, and let each turn end.
- Look at
background_tasksin 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()). KFtkeeps every task for whichBm(task)is true.Bmreturns true whenstatusisrunningorpendingand the task is not foreground. It does not checkambient.monitor_wsgets no extra fields (case "monitor_ws": break).Ite(task)returnstask.skipTranscript === true || (task.type === "monitor_ws" && task.ambient === true). The description attached toambientelsewhere 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_backgroundcomputation (Iv) filters with!Ite(n), and the SDKsystem/background_tasks_changedevent setsambient: trueon these entries. - The hook schema for
background_tasksitems (id,type,status,description,command,agent_type,server,tool,name) has noambientfield.
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/Bmfilter, theItepredicate, theambientdescription, theIvexclusion, and the hook item schema withoutambient. - Observed on one machine through cmux's state file, which records whether
background_taskshad arunningentry at the lastStop: 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
runningentry in those payloads was the artifactmonitor_wswatcher. The rawStoppayloads were not logged (cmux redacts them), so the exact entry was not seen.
Request
Either of these would fix it:
- Leave ambient tasks (
Ite(task) === true) out ofbackground_tasksin theStop/SubagentStoppayloads, consistent with the field's description ("in-flight background work"), or - Add
ambient: trueto each such entry, asbackground_tasks_changedalready 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).