You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A request came in to move the webui state stream from SSE to WebSocket on the
grounds that "SSE cannot be bidirectional, and /goal / /permission need the
client to push requests to the Agent."
After reading the current implementation, that premise does not hold for those
two features: the upstream half of the conversation is already HTTP POST, and
it stays HTTP POST under WebSocket. This issue documents where SSE actually
costs us, where it does not, what a WebSocket migration would buy, what it would
cost, and what evidence would settle the question.
No code change is proposed here. This is a decision record, so the next person to
look at the transport does not have to re-derive it.
The architecture as it actually is
/api/events is a snapshot channel, not an event log.
packages/webui/server/lib/state-bus.js:
_schedulePush (line 421) diffs each payload against the last one written to
that client and skips the write when nothing changed.
Writes inside STATE_PUSH_THROTTLE_MS (line 380, env-tunable) coalesce — N
broadcasts collapse to one write, last snapshot wins.
When the stored res differs from the current one, the diff cache is
discarded and a full snapshot is sent (line 430ff). That is the
fresh-connection and the reconnect path.
The message transcript reaches the same snapshot, but it is sourced by a server-side poll of the runtime SQLite DB, not by the ACP event stream: server/lib/transcript-sync.js defaults to DEFAULT_TRANSCRIPT_SYNC_MS = 4000
and transcriptChanged uses a length + last-line comparison specifically to
avoid hashing 200KB every tick (its comment at line 40).
There are two long-lived SSE connections: /api/events (state) and /api/alerts (anomaly channel). The client's frame contract lives in webapp/lib/sse.ts, deliberately separated from the React store so it is
testable without a DOM.
Where the "bidirectional" concern actually lands
The client-to-server half is 26 endpoints in server/app.js — 22 GET plus 26
POST/PUT/DELETE:
POST /api/send POST /api/permissions
POST /api/stop POST /api/auth/decision
POST /api/cmd POST /api/answer
POST /api/set-model POST /api/protocol/set-mode
POST /api/protocol/cancel POST /api/protocol/set-config-option
POST /api/sessions/* POST /api/fs/mkdir
POST /api/upload POST /api/workspace[/pick]
The permission flow is already a complete request/response round trip. From server/lib/authorize.js:1-7:
authorize(action, ctx, opts) blocks on user confirmation; the UI pops a modal
listening for the needs_authorization SSE event. The user accepts or
declines; the server resolves the pending promise via POST /api/auth/decision.
The server holds the pending promise keyed by request id, SSE pushes the
request down, the client POSTs the decision back, the server resolves. That is
the standard "downstream push + upstream POST" shape, and it does not use the SSE
connection for the reply. A WebSocket does not change it.
One auth detail that is often cited in WebSocket's favour does not survive
contact with the browser.server/lib/auth.js:10 explains that the token
travels as ?token=<value>:
?token=<value> query string (for SSE EventSource — browsers cannot set
headers on EventSource)
The browser WebSocket constructor cannot set an Authorization header
either. The existing withClientQuery() helper is reused unchanged, and the
token-in-URL exposure is identical. WebSocket buys nothing on auth.
What SSE genuinely costs
A three-place tax per new control flow. Every "server asks the client"
flow needs a named SSE event added to the event table, a POST endpoint, and a
pending-promise map. needs_authorization → /api/auth/decision is one
instance; the next one pays the same toll. It is an ergonomics cost, not a
capability gap, but it compounds as control flows multiply.
No cross-transport ordering. A POST response and an SSE snapshot travel
on different connections, so the client can observe them in either order. In
principle a client can see a decision take effect in its own UI while the
pushed state still reflects the pre-decision value. A single WebSocket
connection gives strict per-connection ordering. I have not reproduced a
user-visible instance of this — it is a structural argument, not a filed bug.
Two of six HTTP/1.1 per-origin connections are held open by /api/events and /api/alerts. Plenty of headroom for a local desktop app,
but it is a constraint rather than a non-issue, and it does not exist if the
server is ever fronted by HTTP/2.
No server-initiated request primitive. If the server ever needs to ask
the client something and await the answer on the same channel, today that
must be modelled as a named event plus a POST. More awkward than a
request/response frame pair, though not impossible.
What WebSocket would actually buy
One connection instead of two-plus-N. Fewer sockets and less per-request
header overhead; relevant only at high event volume.
Strict ordering between command results and pushed state (point 2 above).
A request/response frame pair on one channel, removing the three-place tax
for future control flows.
Binary framing, if the payload ever needs it. It does not today — the
stream is JSON snapshots.
What WebSocket would cost
The migration has already been attempted and reverted, which is the most
load-bearing fact in this issue.
f1677c8 (merged via feat(webui): ws event stream, worker embed transport, capability negotiation #20) was titled refactor(webui): remove SSE, switch to WebSocket /api/stream + REST. Note the shape: WebSocket for the stream,
REST for commands — the same upstream/downstream split as today, only the
downstream transport changed.
8698ee0 (Feat/webui nextjs framework #18) deleted the implementation 7 hours later: server/lib/ws-frame.js (399 lines), server/lib/ws-server.js (288 lines),
and three test files (1140 lines). /api/stream does not exist on main
today; the state stream is SSE.
server/app.js documents the remaining direction as incremental: the SSE
channel "is not migrated yet", with SSE migration marked P2.
So the cost is a measured one: ~1800 lines of framing, ring buffer and tests,
to be hand-written and then hand-maintained. The compensation is that EventSource gives reconnection, retry:, and header handling for free, while a
WebSocket client must implement backoff and reconnection itself.
The resume argument does not survive this design
The WebSocket work included seq-based resume with a ring buffer and resume-underrun rollback. That machinery is well built, but it buys nothing
for a snapshot channel. Because /api/events sends the current full state on
every (re)connect, a dropped connection is already recovered by the next
snapshot — there is no missed-delta log to replay. Frame-level resume would only
matter if the stream became an event log, which is a different product
decision, not a transport change.
The thinking effort precedent
Worth recording as a design-intent data point: the feat/webui-react branch
shipped a five-option thinking-effort selector whose setThinking only updated a
local slice and re-posted the unchanged model
(core/services/model-service.ts:228-239) — the value never reached the server
or the engine. A control affordance whose return path is not wired is worse than
no affordance, and it is easy to ship one by accident. Any transport change should
be held to the same standard: every frame must have a verified consumer.
Recommendation
Do not migrate the transport to WebSocket to unblock /goal or /permission. Both already have working upstream paths, and switching the
stream transport would not change them.
If /goal is the actual need, the gap is a missing endpoint, not a missing
transport: cs.goal is written in exactly one place today, server/routes/debug.js:23-25, behind a DEBUG_INJECT !== "1" gate whose stated
purpose is mocking state for browser UI work. routes/protocol.js:77 passes goal_mode through without recording it. A first-class POST /api/goal shaped
like authorize — hold a promise, apply, pushStateFor — would give goal a
real round trip on the current transport, at a fraction of the cost.
A transport migration is worth reopening only if a concrete requirement shows
up that the snapshot-plus-POST shape cannot meet. Before that, the two questions
worth answering are:
How many server-initiated control flows are expected next? The
three-place tax is the strongest real argument for WebSocket, and its weight
scales with that count. Today there is one (needs_authorization).
What I did not measure
Stated plainly so this is not over-read:
No load test comparing SSE snapshot throughput against WebSocket frame
throughput at realistic event rates.
No measurement of current reconnect behaviour under induced drops, so the
practical cost of a gap during a reconnect is unknown.
Point 2 of "what SSE costs" is structural; I have not observed it failing.
Live-service and cross-platform acceptance are out of scope for this
repository's offline verification.
Summary
A request came in to move the webui state stream from SSE to WebSocket on the
grounds that "SSE cannot be bidirectional, and
/goal//permissionneed theclient to push requests to the Agent."
After reading the current implementation, that premise does not hold for those
two features: the upstream half of the conversation is already HTTP POST, and
it stays HTTP POST under WebSocket. This issue documents where SSE actually
costs us, where it does not, what a WebSocket migration would buy, what it would
cost, and what evidence would settle the question.
No code change is proposed here. This is a decision record, so the next person to
look at the transport does not have to re-derive it.
The architecture as it actually is
/api/eventsis a snapshot channel, not an event log.packages/webui/server/lib/state-bus.js:_schedulePush(line 421) diffs each payload against the last one written tothat client and skips the write when nothing changed.
STATE_PUSH_THROTTLE_MS(line 380, env-tunable) coalesce — Nbroadcasts collapse to one write, last snapshot wins.
resdiffers from the current one, the diff cache isdiscarded and a full snapshot is sent (line 430ff). That is the
fresh-connection and the reconnect path.
makeClientState(line 35) carries workspace, model, context, usage,permissions, session title, online count, read-only and token flags.
The message transcript reaches the same snapshot, but it is sourced by a
server-side poll of the runtime SQLite DB, not by the ACP event stream:
server/lib/transcript-sync.jsdefaults toDEFAULT_TRANSCRIPT_SYNC_MS = 4000and
transcriptChangeduses a length + last-line comparison specifically toavoid hashing 200KB every tick (its comment at line 40).
There are two long-lived SSE connections:
/api/events(state) and/api/alerts(anomaly channel). The client's frame contract lives inwebapp/lib/sse.ts, deliberately separated from the React store so it istestable without a DOM.
Where the "bidirectional" concern actually lands
The client-to-server half is 26 endpoints in
server/app.js— 22 GET plus 26POST/PUT/DELETE:
The permission flow is already a complete request/response round trip. From
server/lib/authorize.js:1-7:The server holds the pending promise keyed by request id, SSE pushes the
request down, the client POSTs the decision back, the server resolves. That is
the standard "downstream push + upstream POST" shape, and it does not use the SSE
connection for the reply. A WebSocket does not change it.
One auth detail that is often cited in WebSocket's favour does not survive
contact with the browser.
server/lib/auth.js:10explains that the tokentravels as
?token=<value>:The browser
WebSocketconstructor cannot set anAuthorizationheadereither. The existing
withClientQuery()helper is reused unchanged, and thetoken-in-URL exposure is identical. WebSocket buys nothing on auth.
What SSE genuinely costs
A three-place tax per new control flow. Every "server asks the client"
flow needs a named SSE event added to the event table, a POST endpoint, and a
pending-promise map.
needs_authorization→/api/auth/decisionis oneinstance; the next one pays the same toll. It is an ergonomics cost, not a
capability gap, but it compounds as control flows multiply.
No cross-transport ordering. A POST response and an SSE snapshot travel
on different connections, so the client can observe them in either order. In
principle a client can see a decision take effect in its own UI while the
pushed state still reflects the pre-decision value. A single WebSocket
connection gives strict per-connection ordering. I have not reproduced a
user-visible instance of this — it is a structural argument, not a filed bug.
Two of six HTTP/1.1 per-origin connections are held open by
/api/eventsand/api/alerts. Plenty of headroom for a local desktop app,but it is a constraint rather than a non-issue, and it does not exist if the
server is ever fronted by HTTP/2.
No server-initiated request primitive. If the server ever needs to ask
the client something and await the answer on the same channel, today that
must be modelled as a named event plus a POST. More awkward than a
request/response frame pair, though not impossible.
What WebSocket would actually buy
header overhead; relevant only at high event volume.
for future control flows.
stream is JSON snapshots.
What WebSocket would cost
The migration has already been attempted and reverted, which is the most
load-bearing fact in this issue.
f1677c8(merged via feat(webui): ws event stream, worker embed transport, capability negotiation #20) was titledrefactor(webui): remove SSE, switch to WebSocket /api/stream + REST. Note the shape: WebSocket for the stream,REST for commands — the same upstream/downstream split as today, only the
downstream transport changed.
8698ee0(Feat/webui nextjs framework #18) deleted the implementation 7 hours later:server/lib/ws-frame.js(399 lines),server/lib/ws-server.js(288 lines),and three test files (1140 lines).
/api/streamdoes not exist onmaintoday; the state stream is SSE.
server/app.jsdocuments the remaining direction as incremental: the SSEchannel "is not migrated yet", with SSE migration marked P2.
So the cost is a measured one: ~1800 lines of framing, ring buffer and tests,
to be hand-written and then hand-maintained. The compensation is that
EventSourcegives reconnection,retry:, and header handling for free, while aWebSocket client must implement backoff and reconnection itself.
The resume argument does not survive this design
The WebSocket work included
seq-based resume with a ring buffer andresume-underrunrollback. That machinery is well built, but it buys nothingfor a snapshot channel. Because
/api/eventssends the current full state onevery (re)connect, a dropped connection is already recovered by the next
snapshot — there is no missed-delta log to replay. Frame-level resume would only
matter if the stream became an event log, which is a different product
decision, not a transport change.
The
thinking effortprecedentWorth recording as a design-intent data point: the
feat/webui-reactbranchshipped a five-option thinking-effort selector whose
setThinkingonly updated alocal slice and re-posted the unchanged model
(
core/services/model-service.ts:228-239) — the value never reached the serveror the engine. A control affordance whose return path is not wired is worse than
no affordance, and it is easy to ship one by accident. Any transport change should
be held to the same standard: every frame must have a verified consumer.
Recommendation
Do not migrate the transport to WebSocket to unblock
/goalor/permission. Both already have working upstream paths, and switching thestream transport would not change them.
If
/goalis the actual need, the gap is a missing endpoint, not a missingtransport:
cs.goalis written in exactly one place today,server/routes/debug.js:23-25, behind aDEBUG_INJECT !== "1"gate whose statedpurpose is mocking state for browser UI work.
routes/protocol.js:77passesgoal_modethrough without recording it. A first-classPOST /api/goalshapedlike
authorize— hold a promise, apply,pushStateFor— would give goal areal round trip on the current transport, at a fraction of the cost.
A transport migration is worth reopening only if a concrete requirement shows
up that the snapshot-plus-POST shape cannot meet. Before that, the two questions
worth answering are:
the reason. If the WS removal was accidental rather than a considered
decision, this issue should be rewritten around restoring feat(webui): ws event stream, worker embed transport, capability negotiation #20's work; if it
was considered, the reasoning should be recorded so it does not get
re-proposed.
three-place tax is the strongest real argument for WebSocket, and its weight
scales with that count. Today there is one (
needs_authorization).What I did not measure
Stated plainly so this is not over-read:
throughput at realistic event rates.
practical cost of a gap during a reconnect is unknown.
repository's offline verification.
References
feat/webui-react; carries a portable WebSocket server implementationand the documented
detachedprocess-group fix (see fix(webui): reap the mcode acp process group and group models by provider #23)/api/modelsprovidergrouping from feat(webui-react): four-column workspace UI, tabbed right sidebar, session isolation [draft] #22; deliberately does not carry the transport change