Skip to content

feat(cli,app): show when a session still has background work running - #1680

Open
chphch wants to merge 1 commit into
slopus:mainfrom
chphch:feat/background-work-indicator
Open

feat(cli,app): show when a session still has background work running#1680
chphch wants to merge 1 commit into
slopus:mainfrom
chphch:feat/background-work-indicator

Conversation

@chphch

@chphch chphch commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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. thinking cannot 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 Stop hook payload already carries background_tasks — one entry per in-flight item, { id, type, status, description, command? }, where type is shell, subagent, workflow, monitor, or the raw discriminant for kinds added later. happy-cli already registers a SessionStart hook, so this registers Stop and SessionEnd alongside it, folds the array into counts, and publishes them as metadata.activity.

Reading it from the hook rather than the SDK stream is what makes it work in both launch modes: the claude binary fires its hooks whether happy-cli drives it through the PTY (claudeLocal) or the SDK (claudeRemote).

metadata.activity is a shape MetadataSchema already 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 than thinking's #007AFF, so it reads as "still working, but not on your turn".
  • SessionEnd clears the counts, so a session that exits mid-build leaves nothing stale behind.
  • Identical summaries are not re-sent, so idle turns cost no metadata writes.

Server and wire format are untouched — activity rides 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 when thinking flips.

They compose, and neither subsumes the other:

With both, a subagent keeps the session thinking, and anything still running past turn end shows as background.

Verification

typecheck clean 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). The integration-* projects make real API calls and were not run.

Measured end-to-end against a real happy-cli daemon, a real happy-server, and a real claude session told to launch sleep 600 in 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".

`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>
@chphch

chphch commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Proof — measured end-to-end, with the control case

A real happy-cli daemon paired to a standalone happy-server, and a real claude session told to launch sleep 600 as a background shell and then stop. Every screenshot below is taken after the turn ended — the agent is idle, the shell is still running (ps confirmed pid 56994, sleep 600).

The producer first, since the UI cannot be right if the data never arrived. Raw Stop payload the hook server received:

"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 activity = undefined.

The list. This is a colour change, so the dot was sampled as pixels rather than eyeballed:

dot pixel
session with the shell still running rgb(90,200,250) #5AC8FA exactly
idle session, same frame rgb(73,69,79) theme grey
same session after the shell is killed rgb(73,69,79) back to grey

background work in the list

The third row is the one that makes the result mean anything: after killing the shell and driving one more turn, the Stop hook re-fired with "background_tasks":[], metadata.activity went absent, and the same session's dot returned to grey with the pulse gone — indistinguishable from the session that was never busy.

after the background work clears

It also cannot pass for the wrong reason: thinking is #007AFF = rgb(0,122,255), nowhere near the measured value.

Status text: 1 running in background (plus the existing 1 processes activity summary), reverting to online once the work clears.

session header

Hook registration

The generated settings file for the session registers all three forwarders — SessionStart → /hook/session-start, Stop → /hook/stop, SessionEnd → /hook/session-end — and the loaded dist was verified to be this branch's build, not a stale global install.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session list can't distinguish "finished" from "handed off to background work"

1 participant