feat(cli,app): show when a session still has background work running - #1680
feat(cli,app): show when a session still has background work running#1680chphch wants to merge 1 commit into
Conversation
`thinking` only describes the current turn, so a session drops to "waiting"
the moment the turn ends — even when a twenty-minute build, a background
subagent or a workflow is the whole reason the session is still alive. From
the session list there is no way to tell "this finished" from "this handed
off to something that is still running", which is exactly when you want to
leave it alone.
Claude Code already reports this: every Stop hook payload carries
`background_tasks` — `{ id, type, status, description, command? }` per
in-flight item, where type is 'shell', 'subagent', 'workflow', 'monitor' or
the raw discriminant for kinds added later. happy-cli now registers Stop and
SessionEnd hooks alongside the existing SessionStart one, folds that array
into counts, and publishes them as `metadata.activity` — the same shape the
app already parses and renders for Rig sessions, so the server and the wire
format are untouched. Reading it from the hook rather than from the SDK
stream is what makes it work in both launch modes: the `claude` binary runs
its hooks whether happy-cli drives it through the PTY or through the SDK.
On the app side the activity indicators stop being gated on Rig, and the
session dot gains a `background` state — a lighter blue than `thinking`, to
read as "still working, but not on your turn" — with the existing activity
summary spelling out what is running. SessionEnd clears the counts so a
session that exits mid-build leaves nothing stale behind, and identical
summaries are not re-sent, so idle turns cost no metadata writes.
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Proof — measured end-to-end, with the control caseA real The producer first, since the UI cannot be right if the data never arrived. Raw "background_tasks":[{"id":"bfstano54","type":"shell","status":"running",
"description":"Sleep 600 seconds in background","command":"sleep 600"}]which the CLI folded into the session's metadata (decrypted, server-side): "activity": {"subagents":{"running":0,"queued":0,"total":0},
"workflows":{"running":0,"total":0},
"processes":{"running":1},
"tasks":{"pending":0,"inProgress":0,"completed":0,"total":0}}An idle session created alongside it had The list. This is a colour change, so the dot was sampled as pixels rather than eyeballed:
The third row is the one that makes the result mean anything: after It also cannot pass for the wrong reason: Status text: Hook registrationThe generated settings file for the session registers all three forwarders — |



Closes #1628. Supersedes #1612, rebased onto current
main.A session drops to "waiting" the moment its turn ends, so a twenty-minute build, a subagent or a workflow — the reason the session is still alive — is invisible from the list, and people interrupt sessions that are mid-work.
thinkingcannot express this: it only describes the current turn, and from the list there is no way to tell "this finished" from "this handed off to something still running", which is exactly when you want to leave a session alone.How
Claude Code's
Stophook payload already carriesbackground_tasks— one entry per in-flight item,{ id, type, status, description, command? }, wheretypeisshell,subagent,workflow,monitor, or the raw discriminant for kinds added later. happy-cli already registers aSessionStarthook, so this registersStopandSessionEndalongside it, folds the array into counts, and publishes them asmetadata.activity.Reading it from the hook rather than the SDK stream is what makes it work in both launch modes: the
claudebinary fires its hooks whether happy-cli drives it through the PTY (claudeLocal) or the SDK (claudeRemote).metadata.activityis a shapeMetadataSchemaalready defines and the session info screen already renders — the app was only ever missing a producer. On the app side this un-gates the list indicators (they were restricted to Rig sessions) and adds the dot state:background—#5AC8FA, a lighter blue thanthinking's#007AFF, so it reads as "still working, but not on your turn".SessionEndclears the counts, so a session that exits mid-build leaves nothing stale behind.Server and wire format are untouched —
activityrides inside the existing encrypted metadata.Relationship to #1587
#1587 (@arozanov) addresses the same annoyance from the other end: it defers
updateThinking(false)until the SDK's background-task set empties, so the turn is not reported idle while a subagent runs. This PR does not touch that file and does not change whenthinkingflips.They compose, and neither subsumes the other:
background_tasks_changedfires whenever the set changes, so it also catches work that starts mid-turn — theStophook cannot.With both, a subagent keeps the session
thinking, and anything still running past turn end shows asbackground.Verification
typecheckclean on both packages. happy-app: 909 tests. happy-cli: 804 unit tests (12 new, covering the summarizer's type routing, queued-vs-running split, unknown task kinds and the dedupe comparison). Theintegration-*projects make real API calls and were not run.Measured end-to-end against a real
happy-clidaemon, a realhappy-server, and a realclaudesession told to launchsleep 600in the background and stop — screenshots and pixel samples in the first comment. Since this is a colour change, the dot was sampled as pixels rather than eyeballed, and the control case (same session after the shell is killed) is included so that "the feature works" is distinguishable from "the dot is always that colour".