feat: brew terminal bridge — chat tab via xterm.js + tmux + claude#25
Merged
Conversation
Empty package init, ResizeFrame pydantic model for WS control frames, and TerminalProcessFacade Protocol that the service depends on. The facade lets tests substitute an in-memory FakeProcess for the real PTY.
Bidirectional pump between a FastAPI WebSocket and a TerminalProcessFacade. Resize control frames parsed via ResizeFrame. Lifecycle managed via TerminalService.attached() context manager. Tests use in-memory FakeProcess — no real PTY in unit tests. Adds an asyncio.sleep(0) yield after creating the pump task so already- buffered output flushes before the receive loop's first iteration; AsyncMock in tests resolves synchronously and would otherwise let the disconnect cancel the pump before it ran.
Forks under a PTY, exec's tmux new-session -A by default. The exec argv is injectable so tests run /bin/cat directly without tmux/claude. close() deliberately does NOT signal the child — for tmux that's the server, which must outlive the WebSocket so reattach works. read() returns b"" on closed-fd OR pre-start, so disconnect cleanup pumps EOF cleanly through the service layer.
- terminal_router replaces chat_router at the existing /api prefix - _require_chat_enabled renamed to _require_terminal_enabled (env var FELLOW_CHAT_ENABLED stays — internal name updated for clarity) - regression test repointed to /api/terminal/ws (was /api/chat/messages) - require_api_key extended to accept ?api_key= query param fallback (WebSocket auth can't send custom headers) Lifespan still calls _wire_chat — chat/ dir not yet removed (Task 5).
- src/brew/chat/, tests/chat/, tests/e2e/test_chat_e2e.py removed - _wire_chat() call dropped from lifespan - CHAT_SCHEMA dropped from init_db - pydantic-ai + anthropic removed from pyproject.toml + uv.lock - FELLOW_ANTHROPIC_API_KEY no longer referenced in tests/conftest.py - FELLOW_CHAT_ENABLED env var kept (gates the terminal endpoint now)
- @xterm/xterm + addon-attach + addon-fit + addon-webgl added - @assistant-ui/react removed (chat-only) - postSse helper removed from api/sse.ts (chat-only) - frontend/src/chat/ deleted entirely App.tsx imports temporarily broken — fixed in next task.
WebSocket connects to /api/terminal/ws (auth via api_key query param —
WS can't send custom headers). FitAddon handles resize, sending
{type:'resize',rows,cols} JSON frames to the backend. WebGL renderer
loaded only when getContext('webgl2') succeeds; falls back transparently
to canvas. App.tsx replaces inline Thread composition with <TerminalTab />.
test-setup.ts gains a matchMedia polyfill — JSDOM lacks it and xterm.js
queries it for theme/preference detection.
CLAUDE.md primes the conversation; settings.json registers the local brew MCP server; brew-web-chat skill auto-triggers at session start. The existing skills/brew/ tree gets copied in at Dockerfile-build time (next task) so claude loads it as a project skill.
- COPY brew-workspace/ into /app/brew-workspace/ - COPY skills/brew/ as a sibling under .claude/skills/brew/ - ENV HOME=/home/appuser so claude reads/writes ~/.claude/ at the volume - docker-compose: persistent claude-state volume mounted at /home/appuser/.claude — survives container rebuilds, holds OAuth tokens after first claude login First-run requires interactive 'docker exec -it brew claude login' once; volume persists across docker compose down/up.
- service.py: await cancelled pump task in finally to avoid "Task was destroyed but it is pending!" warnings - process.py: asyncio.to_thread instead of run_in_executor (codebase convention); collapse three _NOT_STARTED constants to one with format; drop dead _workspace/_session attrs - terminal-tab.tsx: drop bogus 'as unknown as string' theme cast (xterm renders 'var(...)' as invalid color); drop unnecessary try/catch around ws.close() (spec: doesn't throw) - terminal-tab.test.tsx: capture lastSocket directly in MockWebSocket ctor instead of TestWebSocket subclass + recordSocket helper - trim narrating docstrings on router, dependencies, service module, _require_terminal_enabled
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the chat tab's pydantic-ai/Anthropic-API backend with an xterm.js terminal piped to a long-lived
tmux + claudesession inside the brew container. The user's Claude Max subscription powers the chat (claude CLI inherits OAuth via persistent volume); no API tokens billed.Spec:
docs/superpowers/specs/2026-05-02-brew-terminal-bridge-design.md(Pi-local).Architecture
src/brew/terminal/(router → service → facade → process), follows brew's standard layered shape./api/terminal/ws(auth via?api_key=query — WS can't send custom headers;require_api_keyextended to accept either header or query).TmuxPtyProcessforks a PTY runningtmux new-session -A -s claude -c /app/brew-workspace claude— attach-or-create semantics give tab-to-tab session continuity.<TerminalTab />uses@xterm/xtermv6 +@xterm/addon-attach(binary frames) +@xterm/addon-fit(resize, sends{type:'resize',rows,cols}JSON control frames) +@xterm/addon-webgl(probed for WebGL2 before loading; falls back to canvas).brew-workspace/baked into image:CLAUDE.md+.claude/settings.json(registers brew MCP at localhost:8000/mcp) +.claude/skills/brew/(existing skill copied at build time) +.claude/skills/brew-web-chat/(new conversational primer).claude-stateDocker volume holds~/.claude/so OAuth survives container rebuilds.Cleanup (kill list applied)
src/brew/chat/(entire bounded context)tests/chat/,tests/e2e/test_chat_e2e.pyfrontend/src/chat/pydantic-ai+anthropicdependencies removed@assistant-ui/reactdependency removedpostSsehelper inapi/sse.tsFELLOW_ANTHROPIC_API_KEYenv varCHAT_SCHEMAfrominit_dbRepurposed (kept)
FELLOW_CHAT_ENABLEDenv var → now gates/api/terminal/ws_chat_enabledmodule flag inmain.py_require_chat_enabled→ renamed to_require_terminal_enabled(internal name only)/api/terminal/wsTest plan
uv run pytest -v— full suite passing (terminal unit + process integration + disabled-endpoint regression; 373 passed)uv run ruff check --no-cache+ruff format --check+ty checkcleannpm run lint+typecheck+test+buildclean (19 frontend tests)docker build); workspace + tmux + claude verified present (claude --version: 2.1.126)docker exec -it brew claude loginonce, smoke chat via/coffee/