Replace the chat processing banner with a minimal activity indicator and
rebuild the state model underneath it. The old banner was driven by five
overlapping pieces of state (isLoading, canAbortSession, claudeStatus in the
chat, plus two app-level Sets updated in lockstep through four callbacks)
that had to be kept in sync imperatively. Because completion and status
events mutated the *viewed* session's flags regardless of which session they
belonged to, a background session finishing could hide the indicator for a
still-running session, returning to a finished session could briefly show a
stale banner, and a late status reply could override a newer request.
The fix is structural rather than patch-by-patch: a single
Map<sessionId, {statusText, canInterrupt, startedAt}> in useSessionProtection
is now the only source of truth for "this session is working". The indicator,
stop button, composer streaming state, and session protection are all derived
from the viewed session's entry on render, so there is no stale local copy to
restore or reset when switching sessions. A PENDING_SESSION_ID sentinel
covers the window before a new conversation receives its real session id.
Terminal events delete the entry atomically, which is why the indicator
disappears the instant the final chunk arrives. Stale check-session-status
replies are discarded via an ifStartedBefore guard (an idle reply older than
the entry's startedAt describes a previous request, not the current one).
The second half unifies the provider lifecycle contract, because the frontend
could not be made race-free while each provider terminated differently:
- cursor emitted complete twice per run (result line + process close), which
double-played the completion sound and let a late close-complete clear a
newer request's indicator
- aborts produced two completes (the abort-session reply plus the provider's
own non-aborted one), so cancelling a run played the celebration sound
- codex omitted exitCode; others attached ad-hoc fields (resultText, isError,
isNewSession) the client had to know about
- claude/codex failures ended with only an error event while gemini/cursor
also emit kind:'error' for mid-run stderr noise, so 'error' was ambiguous
between "the run died" and "a process wrote to stderr"
Every run now ends with exactly one complete built by createCompleteMessage()
({sessionId, actualSessionId, exitCode, success, aborted}); abort-session
sends it on behalf of cancelled runs and providers detect the abort and skip
their own. error is demoted to an informational row, so stderr noise no
longer kills the indicator mid-run, and the client celebrates only
success: true completes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WebSocket Module
This module owns the server-side WebSocket gateway used by:
- Chat streaming (
/ws) - Interactive terminal sessions (
/shell) - Plugin WebSocket passthrough (
/plugin-ws/:pluginName)
It is intentionally structured as small services plus a barrel export in index.ts.
Public API
server/modules/websocket/index.ts exports:
createWebSocketServer(server, dependencies)
Creates and wires the sharedwsserver.connectedClientsandWS_OPEN_STATE
Shared chat client registry and open-state constant used by other modules.
Why Dependency Injection Is Used
The module receives runtime-specific functions from server/index.js instead of importing legacy runtime files directly.
Benefits:
- Keeps module boundaries clean (
server/modules/*architecture rule). - Makes each service easier to test in isolation.
- Keeps WebSocket transport concerns separate from provider runtime concerns.
Service Map
| File | Responsibility |
|---|---|
services/websocket-server.service.ts |
Creates WebSocketServer, binds verifyClient, routes connection by pathname |
services/websocket-auth.service.ts |
Authenticates upgrade requests and attaches request.user |
services/chat-websocket.service.ts |
Handles /ws chat protocol and provider command/session control messages |
services/shell-websocket.service.ts |
Handles /shell PTY lifecycle, reconnect buffering, auth URL detection |
services/plugin-websocket-proxy.service.ts |
Bridges client socket to plugin socket |
services/websocket-writer.service.ts |
Adapts raw WebSocket to writer interface (send, setSessionId, getSessionId) |
services/websocket-state.service.ts |
Holds shared chat client set and open-state constant |
High-Level Architecture
flowchart LR
A[HTTP Server] --> B[createWebSocketServer]
B --> C[verifyWebSocketClient]
B --> D{Pathname}
D -->|/ws| E[handleChatConnection]
D -->|/shell| F[handleShellConnection]
D -->|/plugin-ws/:name| G[handlePluginWsProxy]
D -->|other| H[close()]
E --> I[connectedClients Set]
E --> J[WebSocketWriter]
F --> K[ptySessionsMap]
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
I --> M[projects.service broadcastProgress]
I --> N[sessions-watcher.service projects_updated]
Connection Handshake + Routing
sequenceDiagram
participant Client
participant WSS as WebSocketServer
participant Auth as verifyWebSocketClient
participant Router as connection router
participant Chat as /ws handler
participant Shell as /shell handler
participant Proxy as /plugin-ws handler
Client->>WSS: Upgrade Request
WSS->>Auth: verifyClient(info)
alt Platform mode
Auth->>Auth: authenticateWebSocket(null)
Auth->>Auth: attach request.user
else OSS mode
Auth->>Auth: read token from ?token or Authorization
Auth->>Auth: authenticateWebSocket(token)
Auth->>Auth: attach request.user
end
alt Auth failed
Auth-->>WSS: false (reject handshake)
else Auth ok
Auth-->>WSS: true
WSS->>Router: on("connection", ws, request)
alt pathname == /ws
Router->>Chat: handleChatConnection(ws, request, deps.chat)
else pathname == /shell
Router->>Shell: handleShellConnection(ws, deps.shell)
else pathname startsWith /plugin-ws/
Router->>Proxy: handlePluginWsProxy(ws, pathname, getPluginPort)
else unknown
Router->>Router: ws.close()
end
end
/ws Chat Flow
When a chat socket connects:
- Add socket to
connectedClients. - Build
WebSocketWriter(capturesuserIdfrom authenticated request). - Parse each incoming message with
parseIncomingJsonObject. - Dispatch by
data.type. - On close, remove socket from
connectedClients.
Chat Message Dispatch
flowchart TD
A[Incoming WS message] --> B[parseIncomingJsonObject]
B -->|invalid| C[send {type:error}]
B -->|ok| D{data.type}
D -->|claude-command| E[queryClaudeSDK]
D -->|cursor-command| F[spawnCursor]
D -->|codex-command| G[queryCodex]
D -->|gemini-command| H[spawnGemini]
D -->|cursor-resume| I[spawnCursor resume]
D -->|abort-session| J[abort by provider]
D -->|claude-permission-response| K[resolveToolApproval]
D -->|cursor-abort| L[abortCursorSession]
D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter]
D -->|get-pending-permissions| N[getPendingApprovalsForSession]
D -->|get-active-sessions| O[getActive*Sessions]
Chat Notes
- Unified terminal lifecycle: every provider run ends with exactly one
completemessage built bycreateCompleteMessage()(server/shared/utils.ts), regardless of provider:{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }. Failed runs emit an informationalerrormessage first, then the terminalcompletewithsuccess: false. Mid-runerrormessages (e.g. stderr output) are non-terminal; the frontend only treatscompleteas end-of-run. abort-sessionsends the terminalcomplete(aborted: true) on behalf of the cancelled run; providers detect the abort and skip their owncompleteso the client sees exactly one.check-session-statusreturns{ type: "session-status", isProcessing }.- Claude status checks can reconnect output stream to the new socket via
reconnectSessionWriter.
/shell Terminal Flow
The shell handler manages persistent PTY sessions keyed by:
<projectPath>_<sessionIdOrDefault>[_cmd_<hash>]
This enables reconnect behavior and isolates command-specific plain-shell sessions.
Shell Lifecycle
stateDiagram-v2
[*] --> WaitingInit
WaitingInit --> ValidateInit: message.type == init
ValidateInit --> ReconnectExisting: session key exists and not login reset
ValidateInit --> SpawnNewPTY: valid path + valid sessionId
ValidateInit --> EmitError: invalid payload/path/sessionId
ReconnectExisting --> Running: attach ws, replay buffer
SpawnNewPTY --> Running: pty.spawn + wire onData/onExit
Running --> Running: input -> pty.write
Running --> Running: resize -> pty.resize
Running --> Running: onData -> buffer + output + auth_url detection
Running --> Exited: onExit
Running --> Detached: ws close
Detached --> Running: reconnect before timeout
Detached --> Killed: timeout reached -> pty.kill
Exited --> [*]
Killed --> [*]
EmitError --> WaitingInit
Shell Behaviors in Detail
init: ReadsprojectPath,sessionId,provider,hasSession,initialCommand,isPlainShell.- Login reset: For login-like commands, existing keyed PTY session is killed and recreated.
- Validation:
Path must exist and be a directory;
sessionIdmust match safe pattern. - Command build: Provider-specific command construction with resume semantics.
- PTY output buffering: Stores up to 5000 chunks for replay on reconnect.
- URL detection:
Strips ANSI, accumulates text buffer, extracts URLs, emits
auth_urlonce per normalized URL, supportsautoOpen. - Close behavior: Socket disconnect does not instantly kill PTY; session is kept alive and terminated on timeout.
/plugin-ws/:pluginName Proxy Flow
sequenceDiagram
participant Client
participant Proxy as handlePluginWsProxy
participant PM as getPluginPort
participant Upstream as Plugin WS
Client->>Proxy: Connect /plugin-ws/:name
Proxy->>Proxy: Validate pluginName regex
alt Invalid name
Proxy-->>Client: close(4400, "Invalid plugin name")
else Valid
Proxy->>PM: getPluginPort(name)
alt Plugin not running
Proxy-->>Client: close(4404, "Plugin not running")
else Port found
Proxy->>Upstream: new WebSocket(ws://127.0.0.1:port/ws)
Client-->>Upstream: relay messages bidirectionally
Upstream-->>Client: relay messages bidirectionally
Upstream-->>Client: close propagation
Client-->>Upstream: close propagation
Upstream-->>Client: close(4502, "Upstream error") on upstream error
end
end
Shared Client Registry and Broadcasts
Only chat sockets (/ws) are tracked in connectedClients.
That shared set is consumed by:
modules/projects/services/projects-with-sessions-fetch.service.tsBroadcastsloading_progresswhile project snapshots are being built.modules/providers/services/sessions-watcher.service.tsBroadcastsprojects_updatedwhen provider session artifacts change.
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
Writer Adapter (WebSocketWriter)
WebSocketWriter normalizes chat transport behavior to match existing writer-style interfaces used elsewhere.
Methods:
send(data)
JSON-serializes and sends only if socket is open.setSessionId(sessionId)/getSessionId()
Supports provider session bookkeeping and resume flows.updateWebSocket(newRawWs)
Allows active session stream redirection on reconnect.
Error Handling and Close Codes
Current explicit close codes in this module:
4400: Invalid plugin name4404: Plugin not running4502: Upstream plugin WebSocket error
Other errors:
- Chat handler catches and emits
{ type: "error", error }. - Shell handler catches and writes terminal-visible error output.
- Unknown websocket paths are closed immediately.
Extending This Module
To add a new websocket route:
- Add a new handler service under
services/. - Extend
WebSocketServerDependenciesinwebsocket-server.service.tsif needed. - Add a new pathname branch in the router.
- Wire dependency injection from
server/index.js. - Keep
index.tsas barrel-only export surface.