Files
claudecodeui/server/modules/websocket/README.md
Haileyesus afc717e69e feat(chat): derive activity indicator from per-session state and unify provider lifecycle events
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>
2026-06-10 19:39:04 +03:00

9.5 KiB

WebSocket Module

This module owns the server-side WebSocket gateway used by:

  1. Chat streaming (/ws)
  2. Interactive terminal sessions (/shell)
  3. 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:

  1. createWebSocketServer(server, dependencies)
    Creates and wires the shared ws server.
  2. connectedClients and WS_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:

  1. Keeps module boundaries clean (server/modules/* architecture rule).
  2. Makes each service easier to test in isolation.
  3. 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:

  1. Add socket to connectedClients.
  2. Build WebSocketWriter (captures userId from authenticated request).
  3. Parse each incoming message with parseIncomingJsonObject.
  4. Dispatch by data.type.
  5. 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

  1. Unified terminal lifecycle: every provider run ends with exactly one complete message built by createCompleteMessage() (server/shared/utils.ts), regardless of provider: { kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }. Failed runs emit an informational error message first, then the terminal complete with success: false. Mid-run error messages (e.g. stderr output) are non-terminal; the frontend only treats complete as end-of-run.
  2. abort-session sends the terminal complete (aborted: true) on behalf of the cancelled run; providers detect the abort and skip their own complete so the client sees exactly one.
  3. check-session-status returns { type: "session-status", isProcessing }.
  4. 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

  1. init: Reads projectPath, sessionId, provider, hasSession, initialCommand, isPlainShell.
  2. Login reset: For login-like commands, existing keyed PTY session is killed and recreated.
  3. Validation: Path must exist and be a directory; sessionId must match safe pattern.
  4. Command build: Provider-specific command construction with resume semantics.
  5. PTY output buffering: Stores up to 5000 chunks for replay on reconnect.
  6. URL detection: Strips ANSI, accumulates text buffer, extracts URLs, emits auth_url once per normalized URL, supports autoOpen.
  7. 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:

  1. modules/projects/services/projects-with-sessions-fetch.service.ts Broadcasts loading_progress while project snapshots are being built.
  2. modules/providers/services/sessions-watcher.service.ts Broadcasts projects_updated when 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:

  1. send(data)
    JSON-serializes and sends only if socket is open.
  2. setSessionId(sessionId) / getSessionId()
    Supports provider session bookkeeping and resume flows.
  3. updateWebSocket(newRawWs)
    Allows active session stream redirection on reconnect.

Error Handling and Close Codes

Current explicit close codes in this module:

  1. 4400: Invalid plugin name
  2. 4404: Plugin not running
  3. 4502: Upstream plugin WebSocket error

Other errors:

  1. Chat handler catches and emits { type: "error", error }.
  2. Shell handler catches and writes terminal-visible error output.
  3. Unknown websocket paths are closed immediately.

Extending This Module

To add a new websocket route:

  1. Add a new handler service under services/.
  2. Extend WebSocketServerDependencies in websocket-server.service.ts if needed.
  3. Add a new pathname branch in the router.
  4. Wire dependency injection from server/index.js.
  5. Keep index.ts as barrel-only export surface.