docs: add terminal websocket implementation plan

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 11:54:55 +08:00
co-authored by Claude
parent 741ff63424
commit 33fac266c7
@@ -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 <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.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 <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)`:
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 <noreply@anthropic.com>"
```
---
## 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]://<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**
```bash
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 `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 <workspaceId>]\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: `<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**
```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 <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**
```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 <noreply@anthropic.com>"
```
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.