This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/docs/superpowers/specs/2026-07-03-terminal-websocket-design.md
T

9.5 KiB

Terminal WebSocket Design

Date: 2026-07-03 Branch: feat/terminal-websocket

Goal

Connect the existing TerminalPanel in the web IDE to the running OpenCode process for the current workspace, so the user can see real stdout/stderr and type input that is sent to the process's stdin. Stop the terminal from being a static mock.

The feature must keep the existing HTTP process controls (start/stop/restart) working, must not break internal/api/router.go shape for non-WebSocket clients, and must not couple the WebSocket lifecycle to the workspace CRUD path.

Decisions

  • One WebSocket per workspace, keyed by workspace ID. Multiple browser tabs on the same workspace open multiple sockets; each gets a copy of the stream. History replay is not required for v1: a socket that connects after output is produced does not see the past output.
  • Bidirectional: server streams stdout+stderr, client streams stdin. Both directions are text frames carrying raw bytes (UTF-8). We do not use a custom envelope or JSON framing in v1.
  • Backend process model gets two new responsibilities: capture child stdout/stderr into a list of subscriber channels, and accept stdin writes. This stays inside internal/process; the API layer is a thin WebSocket adapter.
  • The existing Manager interface grows one method: Subscribe(workspaceID) (Subscription, error). We also add an Input accessor on the service layer for stdin. The surface stays small.
  • Workspace root deletion protection and path-escape protection are unchanged. The WebSocket path is /api/workspaces/:id/process/ws. It does not accept a path query, so there is nothing to validate.
  • Backward compatibility: the existing /process/start|stop|restart|status endpoints keep their signatures and 204 / 200 responses. We only add one new endpoint.
  • Frontend: TerminalPanel now takes workspaceId: string | null. If workspaceId is null, show "select a workspace" empty state. If the workspace's process is not running, the panel still opens the socket; the server closes it with a code that the client surfaces as "process not running, start it from the toolbar".
  • Process exit on the server side: server sends a single text frame containing the literal string \r\n[process exited with code N]\r\n (or "signal: name" for signal exits) and then closes the WebSocket with close code 1000. This is the only framed message — all other bytes are raw stream output.

Architecture

Backend additions

internal/process/manager.go

  • Add Subscribe(workspaceID string) (Subscription, error) to the Manager interface. Subscription is a new type defined in internal/process with two methods:
    • Output() <-chan []byte — receives chunks of stdout+stderr merged. Buffered to a small constant (e.g. 32) to avoid blocking the producer.
    • Close() error — drops the subscription. Idempotent.
  • LocalManager keeps per-session:
    • a map[Subscription]struct{} of subscribers, guarded by sync.Mutex.
    • a io.WriteCloser for stdin (created via cmd.Stdin = ...).
  • On Start, set cmd.Stdout and cmd.Stderr to a pipe. A new goroutine per session reads from the pipe and fans out to every subscriber channel. A non-blocking send (select with default) drops output for slow clients. A 4 KiB read buffer is fine. Replay of past output is not in v1; a future task can add a ring buffer behind a separate accessor.
  • Wait() is called in a separate goroutine; on exit, the manager closes all subscriber channels and stores the exit status on the session.

internal/service/process_service.go

  • New method Subscribe(workspaceID string) (process.Subscription, error) that first calls workspaces.Get (so an unknown workspace returns CodeNotFound — same as Status) and then delegates to processes.Subscribe.
  • New method Input(workspaceID string) (io.WriteCloser, error) that returns the session's stdin writer, or CodeNotFound if the process is not running, or CodeUnavailable if the session is shutting down.
  • New method ExitStatus(workspaceID string) (process.ExitInfo, error) for the handler to retrieve the exit reason after the process terminates.

internal/api/process_handler.go

  • New handler ws that:
    1. Looks up the workspace; 404 if missing.
    2. Looks up the session; if not running, writes 409 Conflict with a JSON {"error":"process not running"} and returns.
    3. Upgrades the connection to a WebSocket using gorilla/websocket (single new dep — see Open Questions).
    4. Spawns two goroutines: a read pump that copies WS text frames to the stdin writer, and a write pump that copies the Subscription.Output() channel to the WS. A done channel cancels both.
    5. On the read pump seeing a Close from the client, it calls stdin.Close() and cancels the writer.
    6. On the write pump seeing the subscription channel close, it reads ExitStatus and writes the exit banner text frame, then closes the socket with code 1000.

go.mod

  • Add github.com/gorilla/websocket v1.5.x (one new dependency, Apache-2.0, widely used, small).

internal/api/router.go

  • Register api.GET("/workspaces/:id/process/ws", procHandler.ws).

Frontend additions

web/src/lib/api/process.ts

  • Add useProcessWebSocket(workspaceId, opts?) hook that:
    • Opens ws[s]://<host>/api/workspaces/:id/process/ws when workspaceId is set and the workspace is selected.
    • Exposes { status, send, close } where:
      • status: "connecting" | "open" | "closed" | "error"
      • send(data: string) — no-op when not open.
      • close() — graceful close.
    • Reconnects with exponential backoff (max 3 attempts) only if the close was unexpected. We do not reconnect if the server sent the exit banner (recognizable by the literal prefix [process exited).
    • Hook returns an onData(cb) subscription mechanism via a ref that TerminalPanel registers into.

web/src/components/terminal/TerminalPanel.tsx

  • Replace the mock welcome banner and writeln calls with a subscription to the live WebSocket.
  • Forward user keystrokes (already captured by xterm's onData) to send(...).
  • On status === "closed" with exit banner, print the banner and stop accepting input (set isDead flag).
  • On status === "error", render an inline "terminal error" message but keep xterm mounted.
  • Keep the ResizeObserver fix from the previous task; it stays untouched.

web/src/components/layout/WorkspaceShell.tsx

  • Pass workspaceId down to TerminalPanel.

State and Data Flow

  • The process.Manager is the single source of truth for whether a process is running and what its output is. The WebSocket handler is a read-only consumer of Subscription plus a writer to stdin; it never mutates the process state directly.
  • The WebSocket handler does not call processes.Start. If the process is not running, the socket is refused with 409. The user must use the existing toolbar button.
  • The existing Status polling and the WebSocket are independent. They can disagree transiently (process just exited, status query not yet refetched). The exit banner inside the WS is the authoritative "this process is gone" signal for the terminal panel.

Backward Compatibility

  • internal/api/router.go adds one route. The four existing routes are unchanged.
  • internal/process.Manager interface gains one method. The concrete LocalManager implements it. There are no other implementations in this repo (verified by grep), so this is not a breaking change for downstream code.
  • Existing tests in internal/process/manager_test.go continue to compile and pass — the new method is additive, and tests that don't call Subscribe are unaffected.
  • ProcessStatusResponse is unchanged. No wire-format break for any client that calls /process/status.
  • Frontend: the four existing API hooks for workspaces/files/process are unchanged. The terminal panel's mock data is removed, but the panel is not yet part of any contract that other components rely on.

Open Questions

  1. WebSocket library: gorilla/websocket is the obvious choice and is already used in countless Go services. We could also use the newer coder/websocket (formerly nhooyr.io/websocket). The spec picks gorilla/websocket for stability. Confirm in plan.
  2. Backpressure / client-slow policy: v1 silently drops output for slow clients via non-blocking sends. This is acceptable for an interactive terminal because xterm will show whatever it has and the user can resize. The plan should pick a specific drop policy.
  3. Reconnect: spec says reconnect on transport error, not on exit banner. Should we add a manual "reconnect" button in the terminal panel header? Out of scope for v1.
  4. Authentication: the existing HTTP API has none. The WebSocket follows suit. Out of scope.
  5. Workspace deletion during a live socket: today DELETE /workspaces/:id stops the process. The plan should verify this also closes any open WebSockets for that workspace.

Self-Review Notes

  • The Subscription type and the Input writer are the two new capabilities the manager must grow. They are small and orthogonal.
  • The exit banner format (\r\n[process exited with code N]\r\n) is a simple, debuggable string. We are not building a structured protocol.
  • All v1 design decisions aim to keep the backend diff small, the contract clean, and the frontend's existing terminal lifecycle untouched (same ResizeObserver pattern, same component name).