Repository navigation
tui: ignore foreign-session events until the session is followed #37792
Description
Summary
The V2 TUI projects process-global events into local reactive state without first checking whether the client follows that location, Session, or Session family. Activity in an unrelated Session can therefore allocate transcript indexes, mutate stores, and trigger follow-up API reads in every open TUI.
This is a separate client-side failure boundary from the server subscription fan-out in #36443. Server-side scoping should remove most foreign traffic, but the TUI still needs an explicit interest rule so a protocol regression, reconnect, or intentionally global channel cannot silently repopulate unrelated state.
Environment
- opencode version:
0.0.0-next-15834 - OS: Darwin 25.5.0, arm64
- Terminal: WarpTerminal,
TERM=xterm-256color,COLORTERM=truecolor - Shell:
/bin/zsh - Install/channel: global Bun install,
next - Active plugins: three local user plugins; names and paths omitted because they are unrelated to TUI event projection
Reproduction
- Keep two V2 TUIs open on unrelated Sessions, ideally in unrelated locations.
- Run a tool-heavy turn in Session A while Session B remains idle.
- Observe the process-global
/api/eventstream in both clients. - Inspect Session B's
DataProviderstate after events for Session A arrive.
In a current production capture with nine TUIs and eight running Sessions, unrelated clients received identical stream-byte deltas. Source inspection at the exact running revision shows that each client dispatches all of those events and that many Session event handlers materialize state by event-owned Session ID.
Expected Behavior
- A TUI should project Session events only for Sessions it explicitly follows.
- Following should include the active Session, hydrated Sessions the UI intentionally retains, and explicitly followed child Sessions needed for cross-location subagent UX.
- An event for an unfollowed Session should not allocate transcript, pending-input, activity, permission, form, or message-index state.
- When a Session becomes followed later, its authoritative state should hydrate from the Session APIs rather than relying on unrelated historical live events.
- Location invalidations should refresh only locations already loaded or explicitly followed by the client.
Actual Behavior
ClientProvider opens unscoped api.event.subscribe() and emits every decoded event to local listeners.
DataProvider forwards every non-server.connected event to handleEvent. There is no location or Session-interest check at that boundary.
The message helper always creates both structures for an event-owned Session ID:
function update(sessionID, fn) {
setStore("session", "message", produce((draft) => {
fn((draft[sessionID] ??= []), index(sessionID))
}))
}See data.tsx:170-178. index(sessionID) also creates and retains a Map for a previously unseen Session at data.tsx:218-224.
Many handlers call this helper without requiring loaded Session info or an existing transcript, including:
session.agent.selectedsession.input.admittedsession.instructions.updatedsession.synthetic- shell start/end
- step start/end/failure
- text, reasoning, and tool streaming events
- execution and compaction events
The complete switch begins at data.tsx:296. Other event types create foreign active, pending, input, permission, or form entries, or invalidate and refetch data for an event-owned location.
Additional Context
Why this matters independently of network fan-out
The production incident showed older TUIs at sustained 13-59% CPU, with an earlier 122% spike, and 413 MiB to 2.1 GiB of reported resident memory per client. This issue does not claim that foreign projection alone retains all of that memory. It establishes a deterministic multiplier: every globally delivered event enters every client's reactive graph, and some events create durable local keys for Sessions the client never opened.
Server-side scoping in #36443 is the primary transport fix. A client-side interest gate is still needed as defense in depth and can reduce projection work before the protocol changes land.
Interest semantics to define
The gate should be based on an explicit followed set, not only the currently visible route. V2 intentionally supports child Sessions and cross-location work, so a child followed by an active parent may remain eligible even when its own route is not visible. Conversely, merely receiving a foreign session.created event should not make that Session followed or force an immediate full sync in every TUI.
Acceptance criteria
- A client hydrated only with Session B receives representative Session A input, step, text, reasoning, tool, execution, permission, form, and compaction events without creating any Session A store or message-index entry.
- The same events still project for explicitly followed Session A.
- A followed child Session can project while its parent route is active, including when the child belongs to another location.
- Transitioning a Session from unfollowed to followed hydrates a complete authoritative snapshot and then applies live events without gaps or duplicates.
- Foreign location update events do not trigger model, provider, MCP, command, agent, skill, or integration requests unless that location is loaded or followed.
- Reconnect and duplicate-event tests preserve the same rules.
- A regression test asserts both state and side effects: no foreign keys, no foreign message index, and no follow-up HTTP request.
Related
- 2.0: Scope event streams and bound event payloads #36441 is the event fan-out umbrella.
- 2.0: Scope event subscriptions by client interest #36443 scopes delivery by client interest and remains the primary server-side fix.
- 2.0: Bound event projections and fetch large data by ID #36444 bounds event payloads.
- 2.0: Enforce event-stream ownership, cleanup, and diagnostics #36445 covers stream ownership and cleanup.
No Session IDs, titles, directories, prompts, or event payload contents are included in this report.
Activity