Skip to content

fix(sessions): let hidden pages release idle event streams - #1328

Open
agegr wants to merge 4 commits into
mainfrom
fix/issue-1283-hidden-tab-release
Open

agegr wants to merge 4 commits into
mainfrom
fix/issue-1283-hidden-tab-release

Conversation

@agegr

@agegr agegr commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Over HTTP/1.1 a browser opens at most six connections per origin, shared
by all its tabs. Every pi-web tab kept the selected session's agent event
stream open even while idle (the warm-session effect; the idle grace in
scheduleEventStreamClose() skips the selected session), plus the active
file tab's watch while the right panel was open. A few background tabs
filled the pool, so Settings loads and message sync queued in the browser
until one tab closed.

A page hidden for HIDDEN_PAGE_GRACE_MS (30 s, hooks/usePageHidden.ts)
now asks GET /api/agent/[id] whether anything is under way: a run, a !
command, a compaction, an open extension dialog, or an unsettled subagent
at any depth (new subagentsActive, hasUnsettledSubagentRun()). Only when
nothing is does it close the selected session's stream
(canReleaseHiddenEventStream()); otherwise it checks again each grace. A
quick tab switch closes nothing, and a run that settles while hidden
starts the grace again, which covers an extension's follow-up right after
prompt_done. eventStreamReleasedRef keeps shouldMaintain() and the lease
renewal from reopening it. On return the stream reopens and the session
is read again with its state, applied as the mount does
(applyLoadedAgentState()), so work still under way shows as such; nothing
is resent.

The file watch pauses after the same grace. On reconnect the viewers
compare the file's size and mtime (now in type=meta and type=read, the
pair the watch's change events carry) and reload only a changed file, so
coming back keeps a text file's Load more chunks and scroll position,
media playback and a PDF's place.

What a released page no longer hears: a run started in the session after
the release (another device or tab, an extension timer, a subagent
notification between two checks) plays no chime and shows no in-page
completion or attention notification in that tab until it returns. Web
push still announces completion when subscribed. Terminal streams are
left alone: the server kills a shell that has had no listener for two
minutes. Feedback in the UI when requests are stuck is not addressed.

Refs #1283

Trade-off to decide in review

A hidden tab that has released its stream no longer plays the chime or shows in-page completion/attention notifications for a run started later from elsewhere (another device or tab, an extension timer). Web push still covers completion. Before this change every open tab held one idle stream (two with a file open), which is what fills the six-connection HTTP/1.1 pool.

Testing

  • hooks/hidden-page-stream.test.mjs (real hook, mocked timers): quick hide/show keeps the stream; unfinished subagent keeps it and re-checks each grace; a server-reported run keeps it; busy state applied on return; a run ending while hidden is released one grace later; a pending dialog keeps it. lib/file-version.test.mjs, lib/subagent-runtime.test.mjs.
  • tsc --noEmit, eslint on changed files, npm test 2790 / 0 fail; the hook tests also on Node 22.
  • Headless Chrome against an isolated dev server: hidden 5 s keeps 2 streams with no new connections; hidden 33 s holds 0; visible again reopens 2; a running /slow command keeps its stream until 30 s after it ends; an unchanged text file is not re-read on return (only type=meta).
  • Two independent reviews. The first found lost subagent-triggered runs, no grace, idle shown on return and the text viewer losing Load more (fixed in the first commit). The second found the idle check itself starving in a full pool (now bounded at 5 s; no answer releases), a window where a finished subagent's parent notice was not yet counted, and a stale git diff gutter after an unchanged reconnect (second commit).
  • The second commit's tests pass on Node 24 and Node 22; npm test 2793 / 0 fail. The headless smoke test was not rerun after the second commit.

🤖 Generated with Claude Code

agegr and others added 3 commits October 18, 2026 22:10
Over HTTP/1.1 a browser opens at most six connections per origin, shared
by all its tabs. Every pi-web tab kept the selected session's agent event
stream open even while idle (the warm-session effect; the idle grace in
scheduleEventStreamClose() skips the selected session), plus the active
file tab's watch while the right panel was open. A few background tabs
filled the pool, so Settings loads and message sync queued in the browser
until one tab closed.

A page hidden for HIDDEN_PAGE_GRACE_MS (30 s, hooks/usePageHidden.ts)
now asks GET /api/agent/[id] whether anything is under way: a run, a `!`
command, a compaction, an open extension dialog, or an unsettled subagent
at any depth (new subagentsActive, hasUnsettledSubagentRun()). Only when
nothing is does it close the selected session's stream
(canReleaseHiddenEventStream()); otherwise it checks again each grace. A
quick tab switch closes nothing, and a run that settles while hidden
starts the grace again, which covers an extension's follow-up right after
prompt_done. eventStreamReleasedRef keeps shouldMaintain() and the lease
renewal from reopening it. On return the stream reopens and the session
is read again with its state, applied as the mount does
(applyLoadedAgentState()), so work still under way shows as such; nothing
is resent.

The file watch pauses after the same grace. On reconnect the viewers
compare the file's size and mtime (now in type=meta and type=read, the
pair the watch's change events carry) and reload only a changed file, so
coming back keeps a text file's Load more chunks and scroll position,
media playback and a PDF's place.

What a released page no longer hears: a run started in the session after
the release (another device or tab, an extension timer, a subagent
notification between two checks) plays no chime and shows no in-page
completion or attention notification in that tab until it returns. Web
push still announces completion when subscribed. Terminal streams are
left alone: the server kills a shell that has had no listener for two
minutes. Feedback in the UI when requests are stuck is not addressed.

Refs #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt notices

Second review follow-up to the hidden-page release.

The release waited on GET /api/agent/[id] with no timeout and kept the
stream when no reply came. In the very state #1283 is about, a pool
already full of streams, that request queues in the browser behind them
and never leaves, so no hidden tab ever let go. The check now aborts
after HIDDEN_RELEASE_CHECK_TIMEOUT_MS (5 s); no answer in time, like no
network, releases (canReleaseHiddenEventStream("unanswered")). An error
reply from the server still keeps the stream until the next grace.

A background subagent run leaves the run map before notifyParent() starts
the parent's turn (after waitUntilReady() and the idle poll), so a check
in that window saw no subagent and released. notifyParent() now records
the delivery until it is sent or fails, and hasUnsettledSubagentRun()
counts it.

The text viewer skipped the git diff along with the content on an
unchanged reconnect, leaving the gutter stale after a commit made while
hidden; it reads the diff again.

Refs #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After #1306 status reads go through agentStatusUrl() (?systemPrompt=0).
The hidden-page release asked GET /api/agent/[id] with the full state
every grace per hidden tab; it is a status read too.

Refs #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@agegr
agegr force-pushed the fix/issue-1283-hidden-tab-release branch from 6826260 to 357bc65 Compare October 18, 2026 13:11
… own

The event stream's lease is what keeps the selected session's idle wrapper
from being reaped (#705 liveness, "keep selected sessions alive"). A hidden
page that let go of the stream also let go of that lease and stopped
renewing, so ten minutes later (PI_WEB_IDLE_TIMEOUT_MS) the wrapper shut
down: an extension's timer in it never fired, and nothing ran to announce
by push either, while the session was still selected in that tab.

The page now holds a lease of its own in the stream's place, at the release
and on each renewal tick (POST /api/agent/[id]/lease?holder=<page id>,
holdSessionLivenessLease()): one per page and session, ended a TTL after the
page stops renewing it. The return renews the stream's lease again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant