diff --git a/docs/superpowers/plans/2026-07-03-terminal-websocket.md b/docs/superpowers/plans/2026-07-03-terminal-websocket.md new file mode 100644 index 0000000..38a8f47 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-terminal-websocket.md @@ -0,0 +1,490 @@ +# 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; ring buffer is implemented but not exposed) +- 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, append to a 64 KiB ring buffer (truncate oldest when full, do not expose the buffer in this task), 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. + - 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: explicitly deferred (Task 1 implements it but does not expose it). +- 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.