# Terminal WebSocket Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. This repository has a stricter project rule: all code generation/refactoring must be delegated to Codex MCP unless the change is trivial documentation/config. Every Codex MCP call must include `model: "kimi/kimi-k2.7-code"`, `sandbox: "danger-full-access"`, and `approval-policy: "on-failure"`. **Goal:** Wire the existing `TerminalPanel` in the web IDE to a new Go WebSocket endpoint that streams the OpenCode process's stdout/stderr and accepts stdin. Stop the terminal from being a static mock. **Architecture:** Backend grows a `Subscription` type in `internal/process` that fans out merged stdout+stderr bytes to any number of WS subscribers per workspace session. A new `GET /api/workspaces/:id/process/ws` route upgrades to `gorilla/websocket`, runs a read pump (stdin) and a write pump (subscription output), and closes with a single exit banner frame on process termination. Frontend's `TerminalPanel` replaces its mock banner with a `useProcessWebSocket` hook that exposes `{ status, send, close, onData }` and reconnects on transport failure up to 3 times. **Tech Stack (new):** `github.com/gorilla/websocket` v1.5.x. No other new Go or JS dependencies. **Spec:** `docs/superpowers/specs/2026-07-03-terminal-websocket-design.md`. **Decisions (from spec, confirmed):** - WebSocket library: `gorilla/websocket` - Replay buffer: not in v1 (deferred; a future task can add it behind a separate accessor) - Reconnect: up to 3 attempts with exponential backoff on transport error; never on exit banner ## File Structure Map ### Backend new / changed - Modify `go.mod`, `go.sum`: add gorilla/websocket - Create `internal/process/subscription.go`: `Subscription` type - Modify `internal/process/manager.go`: add `Subscribe`, capture stdout/stderr, fanout - Modify `internal/process/session.go`: add `Stdin`, `Subscribers`, `Exit` fields - Modify `internal/service/process_service.go`: `Subscribe`, `Input`, `ExitStatus` - Modify `internal/api/process_handler.go`: `ws` handler - Modify `internal/api/router.go`: register WS route - Create `internal/api/process_ws_test.go`: integration test using `httptest` ### Frontend new / changed - Modify `web/src/lib/api/process.ts`: `useProcessWebSocket` hook - Modify `web/src/components/terminal/TerminalPanel.tsx`: live WS, no mock - Modify `web/src/components/layout/WorkspaceShell.tsx`: pass `workspaceId` to `TerminalPanel` ### Documentation - Update `README.md`: add WS endpoint to the API table - Update `web/README.md`: note terminal now connects to the real process --- ## Task 1: Add Subscription type and capture process output **Files:** - Modify: `internal/process/manager.go` - Create: `internal/process/subscription.go` - Modify: `internal/process/session.go` **Interfaces:** - New type in `internal/process/subscription.go`: ```go type Subscription interface { Output() <-chan []byte Close() error } ``` - `Session` grows: `Stdin io.WriteCloser`, `Subscribers []Subscription`, `Exit process.ExitInfo` (Code int, Signal string), `mu sync.Mutex`. - `LocalManager.Start`: open stdin pipe, capture writer. Open stdout+stderr merged pipe, capture reader. Spawn a reader goroutine that fans out to subscribers via non-blocking send, and a Wait goroutine that captures exit and closes all subscriber channels. - `LocalManager.Subscribe(workspaceID) (Subscription, error)`: if no session, return `util.New(util.CodeNotFound, "no running process")`. Allocate a buffered channel (cap 32), append, return. **Steps:** - [ ] **Step 1: Read existing process files** Run: `cat internal/process/manager.go internal/process/session.go internal/process/opencode.go internal/process/manager_test.go internal/util/*.go` Confirm: no other implementation of `Manager` exists (`grep -r "process.Manager" --include="*.go"`). Confirm: `Wait()` and signal handling live where expected. - [ ] **Step 2: Run Codex to refactor process model** Use Codex MCP with `model: kimi/kimi-k2.7-code`, `sandbox: danger-full-access`, `approval-policy: on-failure`, prompt: ``` ## Context - Repo: codespace (Go backend). - Read internal/process/manager.go, internal/process/session.go, internal/process/opencode.go, internal/process/manager_test.go, internal/util/*.go first. - Spec: docs/superpowers/specs/2026-07-03-terminal-websocket-design.md. - This is task 1; only the process model changes here. ## Task Add output capture, stdin pipe, subscription fanout, and exit capture to the OpenCode process model. Implement the Subscription type. ## Requirements 1. Create internal/process/subscription.go with a Subscription interface { Output() <-chan []byte; Close() error } and a concrete unexported type that backs onto a buffered channel (cap 32). Close() is idempotent and removes the subscription from the session's subscriber list. 2. Modify internal/process/session.go: add Stdin io.WriteCloser, Subscribers []Subscription, Exit process.ExitInfo (Code int, Signal string), mu sync.Mutex. Use a slice, not a map. 3. Modify internal/process/manager.go: - In LocalManager.Start: open a pipe for cmd.Stdin, capture the write end. Open a pipe for cmd.Stdout AND cmd.Stderr, point both at the same reader, capture the reader. - Spawn goroutine A: read from the merged pipe in a 4 KiB loop, and for each Subscription in session.Subscribers do a non-blocking send (select with default that drops the chunk). Stop when the pipe returns EOF. No replay buffer in v1. - Spawn goroutine B: call cmd.Wait(), populate session.Exit (use ProcessState.ExitCode() and Sys().(syscall.WaitStatus).Signal() if signal), then close every Subscription's channel exactly once. Do not delete the session from the map. - Add LocalManager.Subscribe(workspaceID) (Subscription, error): if no session, return util.New(util.CodeNotFound, "no running process"). Allocate a channel, append a subscription backed by it, return it. 4. Keep existing tests compiling. New Subscribe method is additive. 5. Use util.New(code, msg) and util.Wrap(code, msg, err). ## Constraints - Do not change the Manager interface signature for Start/Stop/Restart/Status. Subscribe is purely additive. - Do not change wire format of ProcessStatusResponse. - No new dependencies (gorilla/websocket is added in a later task). - Keep changes small: one new file plus edits to manager.go and session.go. ## Acceptance - go build ./... exits 0 - go test ./internal/process/... passes ``` - [ ] **Step 3: Validate build and tests** Run: `go build ./... && go test ./internal/process/...` Expected: `go build` exit 0; `go test` passes all existing tests. - [ ] **Step 4: Commit** ```bash git add internal/process go.mod go.sum git commit -m "feat(process): capture stdout/stderr and fan out to subscribers" \ -m "Co-Authored-By: Claude " ``` --- ## Task 2: Expose Subscribe / Input / ExitStatus on the service layer **Files:** - Modify: `internal/service/process_service.go` - (Possibly) Modify: `internal/process/manager.go` to add unexported helpers **Interfaces:** - `Subscribe(workspaceID string) (process.Subscription, error)`: workspaces.Get first, then processes.Subscribe. - `Input(workspaceID string) (io.WriteCloser, error)`: workspaces.Get, then session.Stdin or CodeNotFound. - `ExitStatus(workspaceID string) (process.ExitInfo, error)`: workspaces.Get, then session.Exit or CodeNotFound. **Steps:** - [ ] **Step 1: Run Codex to add the service methods** Use Codex MCP (mandatory params) with prompt: ``` ## Context - Existing service: internal/service/process_service.go. - After Task 1: process.Manager has Subscribe; process.Session has Stdin (io.WriteCloser), Exit (process.ExitInfo), Subscribers. - util codes: util.CodeNotFound, util.CodeInternal. ## Task Add Subscribe, Input, ExitStatus methods to ProcessService. ## Requirements 1. (s *ProcessService) Subscribe(workspaceID string) (process.Subscription, error): workspaces.Get then processes.Subscribe. 2. (s *ProcessService) Input(workspaceID string) (io.WriteCloser, error): workspaces.Get then look up session and return Stdin or CodeNotFound. 3. (s *ProcessService) ExitStatus(workspaceID string) (process.ExitInfo, error): workspaces.Get then look up session and return Exit or CodeNotFound. 4. If the manager interface does not yet expose Stdin/ExitStatus, add unexported helpers on LocalManager (e.g. stdinOf, exitOf) and use them. Only export if the interface must grow. ## Constraints - No new dependencies. No signature changes to existing methods. - At most 3 new methods on the service, plus at most 2 unexported helpers on the manager. ## Acceptance - go build ./... exits 0 - go test ./internal/... passes ``` - [ ] **Step 2: Validate** Run: `go build ./... && go test ./internal/...` - [ ] **Step 3: Commit** ```bash git add internal/service internal/process git commit -m "feat(service): expose subscribe, input, exit status for ws handler" \ -m "Co-Authored-By: Claude " ``` --- ## Task 3: Add the WebSocket handler and route **Files:** - Modify: `go.mod`, `go.sum` (add gorilla/websocket) - Modify: `internal/api/process_handler.go` - Modify: `internal/api/router.go` - Create: `internal/api/process_ws_test.go` **Interfaces:** - Handler `(*processHandler) ws(c *gin.Context)`: 1. Read `id` from path. 2. `h.svc.Input(id)`. If `util.CodeNotFound` → 409 `{"error":"process not running"}`. Other errors → `writeError`. 3. Upgrade with `gorilla/websocket` (CheckOrigin returns true, no auth in v1). 4. `h.svc.Subscribe(id)`. On error → 500. 5. Spawn writer goroutine: 30s ping ticker; on `sub.Output()` channel close, write exit banner `\r\n[process exited with code N]\r\n` (or signal), then `CloseNormalClosure`. 6. Read loop on calling goroutine: 1 MiB read limit, 60s pong deadline, forward text frames to `stdin.Write`. 7. `closeAll` runs once: `sub.Close()` + `conn.Close()` + `stdin.Close()` + `close(done)`. - Route: `api.GET("/workspaces/:id/process/ws", procHandler.ws)`. **Steps:** - [ ] **Step 1: Add the dependency** Run: `go get github.com/gorilla/websocket@v1.5.3` - [ ] **Step 2: Run Codex to implement the handler** Use Codex MCP (mandatory params) with prompt: ``` ## Context - Spec: docs/superpowers/specs/2026-07-03-terminal-websocket-design.md. - Existing handler: internal/api/process_handler.go. - Service: h.svc.Subscribe, h.svc.Input, h.svc.ExitStatus. - util codes: util.CodeNotFound, util.CodeInternal. - error helpers: writeError(c, err), writeBadRequest(c, msg). - Router: internal/api/router.go. - New dep: github.com/gorilla/websocket v1.5.3. ## Task Add the WebSocket handler and register the route. ## Requirements 1. In internal/api/process_handler.go add (h *processHandler) ws(c *gin.Context): a. id := c.Param("id"). b. stdin, err := h.svc.Input(id). If util.CodeNotFound → c.JSON(409, gin.H{"error":"process not running"}) and return. Other errors → writeError. c. sub, err := h.svc.Subscribe(id). On error → writeError and return. d. exit, _ := h.svc.ExitStatus(id) — ignore error. e. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }, ReadBufferSize: 4096, WriteBufferSize: 4096}. f. conn, err := upgrader.Upgrade(c.Writer, c.Request, nil). On error return. g. done := make(chan struct{}); var closeOnce sync.Once; closeAll := func() { closeOnce.Do(func() { sub.Close(); conn.Close(); stdin.Close(); close(done) }) }. h. Goroutine writer: ticker := time.NewTicker(30 * time.Second); defer ticker.Stop() for { select { case <-done: return case <-ticker.C: if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil { closeAll(); return } case chunk, ok := <-sub.Output(): if !ok { conn.WriteMessage(websocket.TextMessage, []byte(exitBanner(exit))) conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) closeAll(); return } if _, err := conn.WriteMessage(websocket.TextMessage, chunk); err != nil { closeAll(); return } } } i. Read loop on calling goroutine: conn.SetReadLimit(1 << 20) conn.SetReadDeadline(time.Now().Add(60 * time.Second)) conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(60 * time.Second)); return nil }) for { mt, data, err := conn.ReadMessage(); if err != nil { break }; if mt == websocket.TextMessage { stdin.Write(data) } } closeAll() j. Define exitBanner(exit process.ExitInfo) string at file scope: if exit.Signal != "" { return fmt.Sprintf("\\r\\n[process exited: signal %s]\\r\\n", exit.Signal) }; return fmt.Sprintf("\\r\\n[process exited with code %d]\\r\\n", exit.Code). 2. In internal/api/router.go add: api.GET("/workspaces/:id/process/ws", procHandler.ws). 3. Create internal/api/process_ws_test.go: - httptest.NewServer wrapping NewRouter(...). - t.TempDir() + script `opencode` that does `read line; echo "got: $line"; sleep 0.1; exit 0`. Make it executable. - Real WorkspaceService + ProcessService with the fake binary path. - Create workspace, start process, open WS, send "hello\n", read frames until exit banner or 3s timeout. - Assert: at least one frame contains "got: hello" and one contains the exit banner. - t.Cleanup: delete workspace. ## Constraints - One new dep: gorilla/websocket. - Do not change existing process handler methods. - Do not introduce a global hub or goroutine leak. - The test must use real ProcessService and real LocalManager. No mocks. - The test must not depend on the user's real opencode binary. ## Acceptance - go build ./... exits 0 - go test ./internal/api/... -run WS passes - go test ./... passes - Router exposes GET /api/workspaces/:id/process/ws ``` - [ ] **Step 3: Validate** Run: `go build ./... && go test ./...` - [ ] **Step 4: Commit** ```bash git add go.mod go.sum internal/api internal/process internal/service git commit -m "feat(api): expose process output over websocket" \ -m "Co-Authored-By: Claude " ``` --- ## Task 4: Frontend WebSocket hook **Files:** - Modify: `web/src/lib/api/process.ts` **Interfaces:** ```ts function useProcessWebSocket(workspaceId: string | null): { status: "idle" | "connecting" | "open" | "closed" | "error", send: (data: string) => void, close: () => void, onData: (cb: (chunk: string) => void) => () => void, // returns unsubscribe } ``` URL: `ws[s]:///api/workspaces/:id/process/ws`. Vite proxy / same-origin Go deployment make `window.location.host` correct in both. Reconnect: 1s, 2s, 4s backoff. Stop after 3 failed attempts → "error". Reset on successful open. Never reconnect on the literal "[process exited" banner or after `close()`. **Steps:** - [ ] **Step 1: Run Codex to add the hook** Use Codex MCP (mandatory params) with prompt: ``` ## Context - Spec: docs/superpowers/specs/2026-07-03-terminal-websocket-design.md. - The hook lives in web/src/lib/api/process.ts alongside useProcessStatus, useProcessStart, useProcessStop, useProcessRestart. ## Task Add the useProcessWebSocket hook. ## Requirements 1. useProcessWebSocket(workspaceId: string | null). Returns { status, send, close, onData }. 2. URL: (window.location.protocol === "https:" ? "wss:" : "ws:") + "//" + window.location.host + "/api/workspaces/" + encodeURIComponent(workspaceId) + "/process/ws". Do NOT consult VITE_API_BASE_URL. 3. status state machine: "idle" (no workspaceId), "connecting", "open", "closed" (after exit banner or after close()), "error" (3 failed reconnects). 4. Exponential backoff: 1s, 2s, 4s, then "error". Reset on successful open. 5. Reconnect only on unexpected close. Do not reconnect on close() or "[process exited" prefix in last frame. 6. send(data) no-op unless "open". Uses socket.send(data). 7. onData(cb) registers, returns unsubscribe. Use a Set of callbacks with ref guarding. 8. On workspaceId change: close old socket, reset attempts, open new. On unmount: close(). ## Constraints - No new deps. Frontend-only. No other file changes. ## Acceptance - pnpm lint passes (pre-existing button.tsx warning acceptable) - pnpm build passes ``` - [ ] **Step 2: Validate** Run: `cd web && pnpm lint && pnpm build` - [ ] **Step 3: Commit** ```bash git add web/src/lib/api/process.ts git commit -m "feat(web): add useProcessWebSocket hook" \ -m "Co-Authored-By: Claude " ``` --- ## Task 5: Wire TerminalPanel to the live WebSocket **Files:** - Modify: `web/src/components/terminal/TerminalPanel.tsx` - Modify: `web/src/components/layout/WorkspaceShell.tsx` **Interfaces:** - `TerminalPanel({ workspaceId: string | null })`. - If `workspaceId` is null, render "Select a workspace to use the terminal." without xterm. - `useProcessWebSocket(workspaceId)`; `onData((chunk) => term.write(chunk))`; `term.onData((input) => send(input))`. - Write one banner line on first open: `\r\n[connected to ]\r\n$ `. - `isDead` flag: when last chunk starts with `[process exited`, set true. While true, ignore `onData` input, show "process exited — use the toolbar to start". - Status hint header (h-6 text-[11px]): "connecting…", "terminal disconnected" (status === "error"), or hidden when "open". - Keep the ResizeObserver + rAF debounce code from the previous fix. - Keep the try/catch around xterm init; if it throws, render the existing "Terminal failed to initialize" fallback and skip the WS lifecycle. - In WorkspaceShell.tsx: ``. **Steps:** - [ ] **Step 1: Run Codex to wire the panel** Use Codex MCP (mandatory params) with prompt: ``` ## Context - Spec: docs/superpowers/specs/2026-07-03-terminal-websocket-design.md. - Current: web/src/components/terminal/TerminalPanel.tsx (mock banner, ResizeObserver fit, no props). - Hook: useProcessWebSocket(workspaceId) returns { status, send, close, onData }. - WorkspaceShell.tsx currently calls . ## Task Make TerminalPanel a real terminal bound to the WebSocket. ## Requirements 1. Props: TerminalPanel({ workspaceId: string | null }). 2. If workspaceId is null → "Select a workspace to use the terminal." (no xterm). 3. Inside panel: - useProcessWebSocket(workspaceId) - on mount of xterm: term.onData((input) => send(input)); unsubscribe on cleanup - onData((chunk) => term.write(chunk)); unsubscribe on cleanup - On first "open", write "\\r\\n[connected to ]\\r\\n$ " - isDead when last chunk starts with "[process exited"; ignore send() while isDead; show "process exited — use the toolbar to start" - "terminal disconnected" when status === "error" - "connecting…" when status === "connecting" 4. Keep the ResizeObserver + rAF debounce. Do not change theme/font/fontSize. 5. Keep the try/catch around xterm init. On throw, render "Terminal failed to initialize" and skip the WS lifecycle. 6. Status hint: h-6 text-[11px] text-muted-foreground header above xterm container. 7. WorkspaceShell.tsx: . ## Constraints - No new deps. Inline messages only. ResizeObserver pattern must not regress. ## Acceptance - pnpm lint passes - pnpm build passes ``` - [ ] **Step 2: Validate** Run: `cd web && pnpm lint && pnpm build` - [ ] **Step 3: Commit** ```bash git add web/src/components/terminal web/src/components/layout git commit -m "feat(web): wire terminal panel to live process websocket" \ -m "Co-Authored-By: Claude " ``` --- ## Task 6: Documentation and final verification **Files:** - Modify: `README.md` - Modify: `web/README.md` **Steps:** - [ ] **Step 1: Update README** Add to the Process table: ``` | `GET` | `/api/workspaces/:id/process/ws` | WebSocket: stream process stdout/stderr, send stdin | ``` Add a short paragraph in `web/README.md`: the terminal now connects to the real OpenCode process when running. - [ ] **Step 2: Run full verification** ```bash go test ./... cd web && pnpm lint && pnpm build ``` - [ ] **Step 3: Commit and summarize** ```bash git add README.md web/README.md git commit -m "docs: document terminal websocket endpoint" \ -m "Co-Authored-By: Claude " ``` Final: ```bash git log --oneline -10 git status --short ``` Expected: working tree clean. --- ## Self-Review Notes ### Spec coverage - WS endpoint at `/api/workspaces/:id/process/ws`: Tasks 1, 2, 3. - Process model grows `Subscribe` and `Stdin`: Tasks 1, 2. - 409 when process not running: Task 3. - Exit banner on close: Task 3. - Replay buffer: not in v1. The manager's reader goroutine reads and fans out directly; no history is kept. - Frontend hook with reconnect policy: Task 4. - TerminalPanel wired to live process: Task 5. - Backward compatibility: existing routes unchanged, Manager interface is additively grown, no wire format changes. - Backpressure: non-blocking send in the process model (Task 1). Slow clients drop chunks silently. ### Type consistency - `process.Subscription` is defined once in `internal/process/subscription.go` and consumed by service and handler. - `process.ExitInfo` is the single source of truth for the exit banner string; the handler formats it. - Frontend `useProcessWebSocket` return shape is documented in Task 4 and consumed by `TerminalPanel` in Task 5. ### Scope check - This plan is one coherent feature unit. It does not include auth, replay, multi-process, signal forwarding, terminal resizing beyond what ResizeObserver already does, or accessibility beyond what xterm provides. - The only "breaking" change is the Manager interface growing methods. There is exactly one implementation in the repo, so the breakage is local.