Skip to content

feat: brew terminal bridge — chat tab via xterm.js + tmux + claude#25

Merged
Leolebleis merged 10 commits into
mainfrom
feat/brew-terminal-bridge
May 2, 2026
Merged

feat: brew terminal bridge — chat tab via xterm.js + tmux + claude#25
Leolebleis merged 10 commits into
mainfrom
feat/brew-terminal-bridge

Conversation

@Leolebleis

Copy link
Copy Markdown
Owner

Summary

Replaces the chat tab's pydantic-ai/Anthropic-API backend with an xterm.js terminal piped to a long-lived tmux + claude session 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

  • New bounded context src/brew/terminal/ (router → service → facade → process), follows brew's standard layered shape.
  • WebSocket endpoint /api/terminal/ws (auth via ?api_key= query — WS can't send custom headers; require_api_key extended to accept either header or query).
  • TmuxPtyProcess forks a PTY running tmux new-session -A -s claude -c /app/brew-workspace claude — attach-or-create semantics give tab-to-tab session continuity.
  • Frontend <TerminalTab /> uses @xterm/xterm v6 + @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).
  • Workspace 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).
  • Persistent claude-state Docker 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.py
  • frontend/src/chat/
  • pydantic-ai + anthropic dependencies removed
  • @assistant-ui/react dependency removed
  • postSse helper in api/sse.ts
  • FELLOW_ANTHROPIC_API_KEY env var
  • CHAT_SCHEMA from init_db

Repurposed (kept)

  • FELLOW_CHAT_ENABLED env var → now gates /api/terminal/ws
  • _chat_enabled module flag in main.py
  • _require_chat_enabled → renamed to _require_terminal_enabled (internal name only)
  • Disabled-endpoint regression test → repointed to /api/terminal/ws

Test 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 check clean
  • npm run lint + typecheck + test + build clean (19 frontend tests)
  • Docker image builds locally (docker build); workspace + tmux + claude verified present (claude --version: 2.1.126)
  • Post-merge manual: deploy to Pi, docker exec -it brew claude login once, smoke chat via /coffee/

Leolebleis added 10 commits May 2, 2026 22:44
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
@Leolebleis Leolebleis merged commit e4901f2 into main May 2, 2026
4 checks passed
@Leolebleis Leolebleis deleted the feat/brew-terminal-bridge branch May 2, 2026 22:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant