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
Managerinterface grows one method:Subscribe(workspaceID) (Subscription, error). We also add anInputaccessor 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 apathquery, so there is nothing to validate. - Backward compatibility: the existing
/process/start|stop|restart|statusendpoints keep their signatures and 204 / 200 responses. We only add one new endpoint. - Frontend:
TerminalPanelnow takesworkspaceId: string | null. IfworkspaceIdis 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 theManagerinterface.Subscriptionis a new type defined ininternal/processwith 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.
LocalManagerkeeps per-session:- a
map[Subscription]struct{}of subscribers, guarded bysync.Mutex. - a
io.WriteCloserfor stdin (created viacmd.Stdin = ...).
- a
- On
Start, setcmd.Stdoutandcmd.Stderrto a pipe. A new goroutine per session reads from the pipe and fans out to every subscriber channel. A non-blocking send (selectwithdefault) 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 callsworkspaces.Get(so an unknown workspace returnsCodeNotFound— same as Status) and then delegates toprocesses.Subscribe. - New method
Input(workspaceID string) (io.WriteCloser, error)that returns the session's stdin writer, orCodeNotFoundif the process is not running, orCodeUnavailableif 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
wsthat:- Looks up the workspace; 404 if missing.
- Looks up the session; if not running, writes 409 Conflict with a JSON
{"error":"process not running"}and returns. - Upgrades the connection to a WebSocket using
gorilla/websocket(single new dep — see Open Questions). - 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. Adonechannel cancels both. - On the read pump seeing a
Closefrom the client, it callsstdin.Close()and cancels the writer. - On the write pump seeing the subscription channel close, it reads
ExitStatusand 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/wswhenworkspaceIdis 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 thatTerminalPanelregisters into.
- Opens
web/src/components/terminal/TerminalPanel.tsx
- Replace the mock welcome banner and
writelncalls with a subscription to the live WebSocket. - Forward user keystrokes (already captured by xterm's
onData) tosend(...). - On
status === "closed"with exit banner, print the banner and stop accepting input (setisDeadflag). - 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
workspaceIddown toTerminalPanel.
State and Data Flow
- The
process.Manageris the single source of truth for whether a process is running and what its output is. The WebSocket handler is a read-only consumer ofSubscriptionplus 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
Statuspolling 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.goadds one route. The four existing routes are unchanged.internal/process.Managerinterface gains one method. The concreteLocalManagerimplements 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.gocontinue to compile and pass — the new method is additive, and tests that don't callSubscribeare unaffected. ProcessStatusResponseis 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
- WebSocket library:
gorilla/websocketis the obvious choice and is already used in countless Go services. We could also use the newercoder/websocket(formerlynhooyr.io/websocket). The spec picksgorilla/websocketfor stability. Confirm in plan. - 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.
- 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.
- Authentication: the existing HTTP API has none. The WebSocket follows suit. Out of scope.
- Workspace deletion during a live socket: today
DELETE /workspaces/:idstops the process. The plan should verify this also closes any open WebSockets for that workspace.
Self-Review Notes
- The
Subscriptiontype and theInputwriter 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).