This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/docs/superpowers/plans/2026-07-03-terminal-websocket.md
T
2026-07-03 11:54:55 +08:00

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 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:
    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

    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

    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

    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 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

    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 --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.