Add a running-session view to the sidebar, including header controls, running counts, empty states, and row-level processing indicators so active provider work is visible outside the current chat. Hydrate running state after refresh through a status-only /api/providers/sessions/running endpoint backed by chatRunRegistry.listRunningRuns, then sync and poll the frontend processingSessions map from AppContent without attaching to chat streams or replaying messages. Preserve fresh local processing entries during sync so newly sent messages are not cleared before the backend registry catches up, and clear completed sessions once the status endpoint no longer reports them. Thread active session state through sidebar project/session components, show rotating loaders for processing sessions, and keep the running search mode expanded and filterable. Fix optimistic local user-message dedupe so repeated prompts are only collapsed when a matching server echo appears from the same send window, preventing sent messages from disappearing until assistant completion. Add registry test coverage for listing currently running app sessions. Tests: npx eslint on changed files; npx tsc --noEmit -p tsconfig.json; npx tsc --noEmit -p server/tsconfig.json; npx tsx --tsconfig server/tsconfig.json --test server/modules/websocket/tests/chat-run-registry.test.ts.
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 the /ws chat protocol (chat.send / chat.abort / chat.subscribe / chat.permission-response) |
services/chat-run-registry.service.ts |
Tracks live provider runs per app session id: seq numbering, event replay buffer, provider-id mapping, completion state |
services/chat-session-writer.service.ts |
Gateway writer handed to provider runtimes: remaps provider session ids to app ids, swallows session_created, assigns seq |
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) for non-chat writer consumers |
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[chatRunRegistry + ChatSessionWriter]
F --> K[ptySessionsMap]
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
I --> M[projects.service loading_progress]
I --> N[sessions-watcher.service session_upserted]
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. - Parse each incoming message with
parseIncomingJsonObject. - Dispatch by
data.type(four message types, none provider-specific). - On close, remove socket from
connectedClients.
Session identity model
The frontend only ever knows the app session id (allocated by
POST /api/providers/sessions or discovered via the session index). The
provider-native id (JSONL file name, CLI resume id) stays inside the backend:
chat.sendresolves the app id to{ provider, provider_session_id, project_path }from the sessions DB.- The provider runtime receives the provider-native id for resume.
- The
ChatSessionWriterremaps every outbound event back to the app id, and turnssession_createdannouncements into a DB mapping update instead of forwarding them.
Chat Message Dispatch
flowchart TD
A[Incoming WS message] --> B[parseIncomingJsonObject]
B -->|invalid| C[send kind:protocol_error]
B -->|ok| D{data.type}
D -->|chat.send| E[resolve session row -> startRun -> spawnFns provider]
D -->|chat.abort| F[abortFns provider + synthetic complete]
D -->|chat.subscribe| G[chat_subscribed ack + attach socket + replay events seq > lastSeq]
D -->|chat.permission-response| H[resolveToolApproval]
D -->|other| I[send kind:protocol_error]
Chat Notes
- Unified envelope: every server-to-client frame carries a
kind— either a providerNormalizedMessagekind or a gateway kind (chat_subscribed,session_upserted,loading_progress,protocol_error). There is no secondtype-based protocol. - Unified terminal lifecycle: every provider run ends with exactly one
completemessage built bycreateCompleteMessage()(server/shared/utils.ts):{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }. The chat handler emits a syntheticcompletefor runs that crash or get aborted, and the run registry drops duplicate completes. - Per-run event log: every live event gets a monotonically increasing
seq.chat.subscribe { sessions: [{ sessionId, lastSeq }] }re-attaches the live stream to the requesting socket (any provider, not just Claude) and replays events withseq > lastSeq. If the buffer no longer coverslastSeq, the client refreshes over REST. chat_subscribedincludesisProcessing(replacescheck-session-status) andpendingPermissions(replacesget-pending-permissions).
/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.tsBroadcastskind: loading_progresswhile project snapshots are being built.modules/providers/services/sessions-watcher.service.tsBroadcasts per-sessionkind: session_upserteddeltas when provider session artifacts change (no full project snapshots).
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
{ kind: "protocol_error", code, 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.