Problem
The v2 /api/event stream is process-global. Every TUI receives every server-visible event across locations, workspaces, and sessions, then independently decodes, logs, projects, and sometimes reacts to it.
With N open TUIs, one source event currently causes approximately:
N listener queue offers in the server event bus
N schema encodes, JSON serializations, and SSE writes
N client decodes and durable-event log operations
N TUI event dispatches and store projections
- potentially
N follow-up refreshes or notifications
This becomes especially expensive because durable events currently contain unbounded payloads. Examples observed in a real next-channel database include:
session.tool.success events up to 10.2 MB, primarily unrestricted structured.output
session.input.admitted events over 1 MB due to inline base64 file attachments
An incident with eleven open TUIs drove the managed server to roughly 280-665% CPU and multiple gigabytes of memory. Every oversized event caused all connected TUIs to fail and reconnect together, amplifying subscriber and connection pressure.
Current behavior
packages/server/src/handlers/event.ts subscribes each /api/event response to the process-global EventV2 service.
isOpenCodeEvent filters by event visibility/type only, not location, workspace, session, or client interest.
EventV2.notify walks all listeners for every event.
- Each response independently runs
Schema.encodeUnknownSync, JSON.stringify, and SSE encoding.
packages/tui/src/context/sdk.tsx decodes and dispatches every event and logs every durable event.
packages/tui/src/context/data.tsx projects foreign-session events into each TUI's store and some event types trigger refresh requests.
- Stream ownership and server-side subscriber counts are not observable.
Desired invariants
- A TUI subscribes only to the locations/workspaces/sessions it needs.
- A session event is delivered once per interested client, not once per connected process.
- Opening unrelated TUIs does not materially increase CPU for an active session.
- Event payloads are bounded independently of tool output and attachment size.
- Large data is fetched by identifier only by interested clients.
- One mounted client owns exactly one active stream; reconnect replaces rather than overlaps it.
- Closing or failing a client stream promptly releases the server response, listener, and queue.
- Server diagnostics expose active stream/subscriber count and event serialization volume.
Proposed workstreams
Scope subscriptions
- Add an explicit subscription scope to the event protocol: project/location, optional workspace, and optional session set.
- Filter before events enter each subscriber queue, not after global fan-out.
- Define behavior for truly global events separately.
- Support changing interest without opening redundant streams, or define one scoped stream per location if that is the simpler ownership model.
Bound event projections
- Replace full
session.tool.success.structured/result payloads with bounded completion metadata and identifiers.
- Remove inline base64 attachment data from
session.input.admitted events.
- Add bounded preview/summary fields only where the TUI needs immediate rendering.
- Let interested clients fetch the complete message, tool result, or attachment through the session/message API.
- Establish and test an encoded event-size budget.
Stream lifecycle
- Make the client stream own transport cancellation and verify real socket closure.
- Ensure reconnect waits for or aborts the previous stream before opening another.
- Verify server HTTP response finalization removes the
EventV2.liveBounded listener and queue.
- Add jitter/backoff for synchronized reconnect waves where appropriate.
Observability
Expose bounded diagnostics for:
- active event-stream responses
- active event-bus subscribers
- opened/closed streams
- serialized events and bytes by event type
- subscriber overflows
- reconnect reason counts
Do not log event payloads or user content.
Regression tests
Correctness
- One mounted TUI has exactly one active stream and zero after teardown.
- Repeated malformed/transport reconnects never exceed one active stream per TUI.
- Eleven TUI clients produce exactly eleven subscriptions and one handling per event ID.
- Destroying five clients reduces active subscriptions from eleven to six.
- Server-side listener count returns to baseline after response cancellation.
- Location/session-scoped clients do not receive unrelated events.
- Encoded event projections remain within the agreed budget.
Performance benchmark
Add a non-gating fan-out benchmark with client counts 1, 2, 4, 8, 11, 16 and small, medium, and large event projections. Record:
- publish CPU time
- serialized records and bytes
- aggregate client handling time
- peak active connections/subscribers
- RSS and event-loop delay
The benchmark should verify the accounting laws even if absolute timing remains non-gating.
Related immediate fix
A separate v2 PR raises the generated Promise SSE event limit from 1 MiB to 16 MiB and reports SseEventTooLarge instead of MalformedResponse. That prevents valid current events from triggering synchronized reconnects, but it does not solve global fan-out or unbounded event payloads.
Implementation issues
Problem
The v2
/api/eventstream is process-global. Every TUI receives every server-visible event across locations, workspaces, and sessions, then independently decodes, logs, projects, and sometimes reacts to it.With
Nopen TUIs, one source event currently causes approximately:Nlistener queue offers in the server event busNschema encodes, JSON serializations, and SSE writesNclient decodes and durable-event log operationsNTUI event dispatches and store projectionsNfollow-up refreshes or notificationsThis becomes especially expensive because durable events currently contain unbounded payloads. Examples observed in a real next-channel database include:
session.tool.successevents up to 10.2 MB, primarily unrestrictedstructured.outputsession.input.admittedevents over 1 MB due to inline base64 file attachmentsAn incident with eleven open TUIs drove the managed server to roughly 280-665% CPU and multiple gigabytes of memory. Every oversized event caused all connected TUIs to fail and reconnect together, amplifying subscriber and connection pressure.
Current behavior
packages/server/src/handlers/event.tssubscribes each/api/eventresponse to the process-globalEventV2service.isOpenCodeEventfilters by event visibility/type only, not location, workspace, session, or client interest.EventV2.notifywalks all listeners for every event.Schema.encodeUnknownSync,JSON.stringify, and SSE encoding.packages/tui/src/context/sdk.tsxdecodes and dispatches every event and logs every durable event.packages/tui/src/context/data.tsxprojects foreign-session events into each TUI's store and some event types trigger refresh requests.Desired invariants
Proposed workstreams
Scope subscriptions
Bound event projections
session.tool.success.structured/resultpayloads with bounded completion metadata and identifiers.session.input.admittedevents.Stream lifecycle
EventV2.liveBoundedlistener and queue.Observability
Expose bounded diagnostics for:
Do not log event payloads or user content.
Regression tests
Correctness
Performance benchmark
Add a non-gating fan-out benchmark with client counts
1, 2, 4, 8, 11, 16and small, medium, and large event projections. Record:The benchmark should verify the accounting laws even if absolute timing remains non-gating.
Related immediate fix
A separate v2 PR raises the generated Promise SSE event limit from 1 MiB to 16 MiB and reports
SseEventTooLargeinstead ofMalformedResponse. That prevents valid current events from triggering synchronized reconnects, but it does not solve global fan-out or unbounded event payloads.Implementation issues