From 741ff634247cc4fc76f5867f59acd174e8acb674 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:48:13 +0800 Subject: [PATCH] docs: add terminal websocket design spec Co-Authored-By: Claude --- .../2026-07-03-terminal-websocket-design.md | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-03-terminal-websocket-design.md diff --git a/docs/superpowers/specs/2026-07-03-terminal-websocket-design.md b/docs/superpowers/specs/2026-07-03-terminal-websocket-design.md new file mode 100644 index 0000000..a0b3311 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-terminal-websocket-design.md @@ -0,0 +1,206 @@ +# 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 an in-memory ring buffer plus 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 `bytes.Buffer` of recent output (cap, e.g. 64 KiB) for any future + replay — implemented but **not exposed in v1**. + - a `[]chan []byte` 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, writes to the ring buffer, 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. +- `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]:///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 ring buffer for replay is implemented but unused in v1. This is + dead weight. **Defer**: the spec keeps it for symmetry with future + replay work. Plan may drop it if not needed. +- 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).