22 KiB
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 includemodel: "kimi/kimi-k2.7-code",sandbox: "danger-full-access", andapproval-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:Subscriptiontype - Modify
internal/process/manager.go: addSubscribe, capture stdout/stderr, fanout - Modify
internal/process/session.go: addStdin,Subscribers,Exitfields - Modify
internal/service/process_service.go:Subscribe,Input,ExitStatus - Modify
internal/api/process_handler.go:wshandler - Modify
internal/api/router.go: register WS route - Create
internal/api/process_ws_test.go: integration test usinghttptest
Frontend new / changed
- Modify
web/src/lib/api/process.ts:useProcessWebSockethook - Modify
web/src/components/terminal/TerminalPanel.tsx: live WS, no mock - Modify
web/src/components/layout/WorkspaceShell.tsx: passworkspaceIdtoTerminalPanel
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:type Subscription interface { Output() <-chan []byte Close() error } Sessiongrows: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, returnutil.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/*.goConfirm: no other implementation ofManagerexists (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 buildexit 0;go testpasses all existing tests. -
Step 4: Commit
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 <noreply@anthropic.com>"
Task 2: Expose Subscribe / Input / ExitStatus on the service layer
Files:
- Modify:
internal/service/process_service.go - (Possibly) Modify:
internal/process/manager.goto 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
git add internal/service internal/process git commit -m "feat(service): expose subscribe, input, exit status for ws handler" \ -m "Co-Authored-By: Claude <noreply@anthropic.com>"
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):- Read
idfrom path. h.svc.Input(id). Ifutil.CodeNotFound→ 409{"error":"process not running"}. Other errors →writeError.- Upgrade with
gorilla/websocket(CheckOrigin returns true, no auth in v1). h.svc.Subscribe(id). On error → 500.- 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), thenCloseNormalClosure. - Read loop on calling goroutine: 1 MiB read limit, 60s pong deadline, forward text frames to
stdin.Write. closeAllruns once:sub.Close()+conn.Close()+stdin.Close()+close(done).
- Read
- 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
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 <noreply@anthropic.com>"
Task 4: Frontend WebSocket hook
Files:
- Modify:
web/src/lib/api/process.ts
Interfaces:
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]://<window.location.host>/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
git add web/src/lib/api/process.ts git commit -m "feat(web): add useProcessWebSocket hook" \ -m "Co-Authored-By: Claude <noreply@anthropic.com>"
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
workspaceIdis 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 <workspaceId>]\r\n$. isDeadflag: when last chunk starts with[process exited, set true. While true, ignoreonDatainput, 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:
<TerminalPanel workspaceId={workspaceId} />.
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 <TerminalPanel />. ## 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 <workspaceId>]\\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: <TerminalPanel workspaceId={workspaceId} />. ## 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
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 <noreply@anthropic.com>"
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
go test ./... cd web && pnpm lint && pnpm build -
Step 3: Commit and summarize
git add README.md web/README.md git commit -m "docs: document terminal websocket endpoint" \ -m "Co-Authored-By: Claude <noreply@anthropic.com>"Final:
git log --oneline -10 git status --shortExpected: working tree clean.
Self-Review Notes
Spec coverage
- WS endpoint at
/api/workspaces/:id/process/ws: Tasks 1, 2, 3. - Process model grows
SubscribeandStdin: 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.Subscriptionis defined once ininternal/process/subscription.goand consumed by service and handler.process.ExitInfois the single source of truth for the exit banner string; the handler formats it.- Frontend
useProcessWebSocketreturn shape is documented in Task 4 and consumed byTerminalPanelin 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.