# Terminal WebSocket Channel 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.

**Goal:** Build a text-only WebSocket channel adapter so a small terminal device can connect to Beaver through the channel runtime and receive final assistant replies.

**Architecture:** Add `TerminalWebSocketAdapter` as a channel-native adapter. The FastAPI route `/api/channels/{channel_id}/ws` delegates WebSocket handling to the adapter, while inbound and outbound traffic still flows through `ChannelRuntime`, `MessageBus`, `AgentService.handle_inbound_message()`, and `ChannelManager.dispatch_outbound()`.

**Tech Stack:** Python 3.14, FastAPI WebSocket, Beaver `ChannelRuntime`, Beaver `MessageBus`, pytest/TestClient, Next.js TypeScript status UI, nginx.

---

## Current Context

This plan assumes channel runtime v1 exists in the working tree:

- `app-instance/backend/beaver/interfaces/channels/runtime.py`
- `app-instance/backend/beaver/interfaces/channels/generic_webhook.py`
- `app-instance/backend/beaver/interfaces/channels/state.py`
- `app-instance/backend/beaver/foundation/events/message_bus.py` with `ChannelIdentity`
- `app-instance/backend/beaver/interfaces/web/app.py` with `/api/channels/{channel_id}/webhook`

Do not route terminal messages to `AgentService` from the WebSocket route. The adapter must call `ChannelRuntime.accept_inbound()` and let the runtime bridge publish outbound replies.

## Files

- Create: `app-instance/backend/beaver/interfaces/channels/terminal_websocket.py`
  - Owns WebSocket connection state, JSON frame validation, connect/message/ping handling, and outbound delivery.
- Modify: `app-instance/backend/beaver/interfaces/channels/runtime.py`
  - Instantiate terminal websocket adapters.
  - Include terminal capabilities, websocket URL, and connected peer counts in status.
  - Provide a small event recording callback for adapters.
- Modify: `app-instance/backend/beaver/interfaces/channels/__init__.py`
  - Export `TerminalWebSocketAdapter`.
- Modify: `app-instance/backend/beaver/interfaces/web/app.py`
  - Add `/api/channels/{channel_id}/ws` WebSocket route that delegates to terminal adapters.
- Modify: `app-instance/nginx.conf`
  - Add WebSocket upgrade headers to `/api/channels/`.
- Modify: `app-instance/frontend/types/index.ts`
  - Add `websocket_url` and `connected_peers` to `ChannelStatus`.
- Modify: `app-instance/frontend/app/(app)/status/page.tsx`
  - Show WebSocket URL and connected peer count in channel details.
- Test: `app-instance/backend/tests/unit/test_terminal_websocket_channel.py`
  - Cover connect, ping, message roundtrip, duplicates, errors, status, and disconnect/unclaimed behavior.

---

### Task 1: Add Failing WebSocket Roundtrip Tests

**Files:**
- Create: `app-instance/backend/tests/unit/test_terminal_websocket_channel.py`

- [ ] **Step 1: Write failing tests for connect, ping, and message roundtrip**

Create `app-instance/backend/tests/unit/test_terminal_websocket_channel.py`:

```python
import asyncio
import json
from pathlib import Path
from typing import Any

from fastapi.testclient import TestClient

from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService


class TerminalFakeAgentService(AgentService):
    def __init__(self, *, config_path: Path, delay_seconds: float = 0.0) -> None:
        super().__init__(config_path=config_path)
        self.delay_seconds = delay_seconds
        self.inbound_calls: list[InboundMessage] = []

    async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
        self.inbound_calls.append(inbound)
        if self.delay_seconds:
            await asyncio.sleep(self.delay_seconds)
        return OutboundMessage(
            message_id=inbound.message_id,
            channel=inbound.channel,
            content=f"echo:{inbound.content}",
            session_id=inbound.session_id,
            finish_reason="stop",
            run_id="run-1",
            channel_identity=inbound.channel_identity,
        )


def write_terminal_config(tmp_path: Path) -> Path:
    workspace = tmp_path / "workspace"
    workspace.mkdir()
    config_path = tmp_path / "config.json"
    config_path.write_text(
        json.dumps(
            {
                "agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
                "providers": {},
                "channels": {
                    "terminal-dev": {
                        "enabled": True,
                        "kind": "terminal",
                        "mode": "websocket",
                        "accountId": "local",
                        "displayName": "Terminal Dev",
                        "config": {"heartbeatSeconds": 30, "maxMessageChars": 20000},
                    }
                },
            }
        ),
        encoding="utf-8",
    )
    return config_path


def test_terminal_websocket_connect_ping_and_message_roundtrip(tmp_path: Path) -> None:
    config_path = write_terminal_config(tmp_path)
    service = TerminalFakeAgentService(config_path=config_path)
    app = create_app(service=service, manage_service_lifecycle=False)

    with TestClient(app) as client:
        with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
            websocket.send_json(
                {
                    "type": "connect",
                    "peer_id": "device-001",
                    "device_name": "desk-terminal",
                    "capabilities": ["text"],
                }
            )
            assert websocket.receive_json() == {
                "type": "connected",
                "channel_id": "terminal-dev",
                "session_id": "terminal-dev:local:device-001",
            }

            websocket.send_json({"type": "ping"})
            assert websocket.receive_json() == {"type": "pong"}

            websocket.send_json(
                {
                    "type": "message",
                    "message_id": "device-001-000001",
                    "text": "hello",
                }
            )
            assert websocket.receive_json() == {
                "type": "ack",
                "message_id": "device-001-000001",
                "session_id": "terminal-dev:local:device-001",
                "accepted": True,
            }
            reply = websocket.receive_json()

    service.close()
    assert reply == {
        "type": "message",
        "role": "assistant",
        "message_id": "device-001-000001",
        "run_id": "run-1",
        "text": "echo:hello",
        "finish_reason": "stop",
    }
    assert len(service.inbound_calls) == 1
    inbound = service.inbound_calls[0]
    assert inbound.channel == "terminal-dev"
    assert inbound.content == "hello"
    assert inbound.content_type == "text"
    assert inbound.session_id == "terminal-dev:local:device-001"
    assert inbound.channel_identity is not None
    assert inbound.channel_identity.peer_id == "device-001"
    assert inbound.channel_identity.peer_type == "terminal"
    assert inbound.channel_identity.message_id == "device-001-000001"
```

- [ ] **Step 2: Run the failing test**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_terminal_websocket_channel.py::test_terminal_websocket_connect_ping_and_message_roundtrip -q
```

Expected: fail because `/api/channels/terminal-dev/ws` does not exist or `terminal/websocket` is unsupported.

- [ ] **Step 3: Commit the failing test**

```bash
git add app-instance/backend/tests/unit/test_terminal_websocket_channel.py
git commit -m "test: cover terminal websocket channel roundtrip"
```

If channel runtime v1 changes are still uncommitted in this working tree, do not include unrelated files in this commit.

---

### Task 2: Implement TerminalWebSocketAdapter

**Files:**
- Create: `app-instance/backend/beaver/interfaces/channels/terminal_websocket.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/__init__.py`

- [ ] **Step 1: Create the terminal websocket adapter**

Create `app-instance/backend/beaver/interfaces/channels/terminal_websocket.py`:

```python
"""Text-only terminal WebSocket channel adapter."""

from __future__ import annotations

from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any

from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink

try:
    from fastapi import WebSocket
    from starlette.websockets import WebSocketDisconnect
except ModuleNotFoundError:  # pragma: no cover - import-only fallback
    class WebSocketDisconnect(Exception):
        """Fallback disconnect exception for skeleton import environments."""

    class WebSocket:  # type: ignore[override]
        """Fallback websocket annotation shim."""


def _clean(value: Any) -> str:
    return str(value or "").strip()


@dataclass(slots=True)
class TerminalConnection:
    websocket: WebSocket
    peer_id: str
    session_id: str
    thread_id: str | None = None
    user_id: str | None = None
    device_name: str = ""
    capabilities: list[str] = field(default_factory=list)


class TerminalWebSocketAdapter:
    """Accept text terminal websocket frames and deliver final assistant replies."""

    def __init__(
        self,
        *,
        channel_id: str,
        kind: str,
        mode: str,
        account_id: str,
        display_name: str = "",
        inbound_sink: ChannelInboundSink,
        event_recorder: Callable[..., None] | None = None,
        heartbeat_seconds: float = 30,
        max_message_chars: int = 20000,
    ) -> None:
        self.channel_id = channel_id
        self.kind = kind
        self.mode = mode
        self.account_id = account_id
        self.display_name = display_name or channel_id
        self.inbound_sink = inbound_sink
        self.event_recorder = event_recorder
        self.heartbeat_seconds = max(1.0, float(heartbeat_seconds))
        self.max_message_chars = max(1, int(max_message_chars))
        self.started = False
        self._connections_by_session: dict[str, TerminalConnection] = {}
        self._session_by_peer: dict[str, str] = {}

    async def start(self) -> None:
        self.started = True

    async def stop(self) -> None:
        self.started = False
        for connection in list(self._connections_by_session.values()):
            with suppress(Exception):
                await connection.websocket.close(code=1001)
        self._connections_by_session.clear()
        self._session_by_peer.clear()

    def status_extra(self) -> dict[str, Any]:
        return {"connected_peers": len(self._connections_by_session)}

    async def handle_websocket(self, websocket: WebSocket) -> None:
        await websocket.accept()
        connection: TerminalConnection | None = None
        try:
            while True:
                try:
                    payload = await websocket.receive_json()
                except WebSocketDisconnect:
                    break
                except ValueError:
                    await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"})
                    continue
                if not isinstance(payload, dict):
                    await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"})
                    continue

                frame_type = _clean(payload.get("type")).lower()
                if frame_type == "ping":
                    await websocket.send_json({"type": "pong"})
                    continue
                if frame_type == "connect":
                    connection = await self._handle_connect(websocket, payload, current=connection)
                    continue
                if frame_type == "message":
                    if connection is None:
                        await websocket.send_json({"type": "error", "error": "connect is required before message"})
                        continue
                    await self._handle_message(websocket, connection, payload)
                    continue

                await websocket.send_json(
                    {
                        "type": "error",
                        "error": f"Unsupported websocket frame type: {frame_type or '<empty>'}",
                    }
                )
        finally:
            if connection is not None:
                self._remove_connection(connection)
                self._record(
                    kind="terminal_disconnected",
                    session_id=connection.session_id,
                    metadata={"peer_id": connection.peer_id, "device_name": connection.device_name},
                )

    async def _handle_connect(
        self,
        websocket: WebSocket,
        payload: dict[str, Any],
        *,
        current: TerminalConnection | None,
    ) -> TerminalConnection | None:
        peer_id = _clean(payload.get("peer_id"))
        if not peer_id:
            await websocket.send_json({"type": "error", "error": "peer_id is required"})
            return current

        thread_id = _clean(payload.get("thread_id")) or None
        user_id = _clean(payload.get("user_id")) or None
        device_name = _clean(payload.get("device_name"))
        capabilities = [str(item) for item in payload.get("capabilities") or [] if item is not None]
        identity = ChannelIdentity(
            channel_id=self.channel_id,
            kind=self.kind,
            account_id=self.account_id,
            peer_id=peer_id,
            thread_id=thread_id,
            peer_type="terminal",
            user_id=user_id,
        )
        session_id = identity.session_id()
        connection = TerminalConnection(
            websocket=websocket,
            peer_id=peer_id,
            session_id=session_id,
            thread_id=thread_id,
            user_id=user_id,
            device_name=device_name,
            capabilities=capabilities,
        )

        if current is not None and current.session_id != session_id:
            self._remove_connection(current)
        old = self._connections_by_session.get(session_id)
        if old is not None and old.websocket is not websocket:
            with suppress(Exception):
                await old.websocket.close(code=1000)
        self._connections_by_session[session_id] = connection
        self._session_by_peer[peer_id] = session_id
        self._record(
            kind="terminal_connected",
            session_id=session_id,
            metadata={"peer_id": peer_id, "device_name": device_name, "capabilities": capabilities},
        )
        await websocket.send_json(
            {
                "type": "connected",
                "channel_id": self.channel_id,
                "session_id": session_id,
            }
        )
        return connection

    async def _handle_message(
        self,
        websocket: WebSocket,
        connection: TerminalConnection,
        payload: dict[str, Any],
    ) -> None:
        message_id = _clean(payload.get("message_id"))
        text = _clean(payload.get("text"))
        if not message_id:
            await websocket.send_json({"type": "error", "error": "message_id is required"})
            return
        if not text:
            await websocket.send_json({"type": "error", "error": "text is required"})
            return
        if len(text) > self.max_message_chars:
            await websocket.send_json(
                {
                    "type": "error",
                    "error": f"text exceeds max_message_chars ({self.max_message_chars})",
                }
            )
            return

        thread_id = _clean(payload.get("thread_id")) or connection.thread_id
        user_id = _clean(payload.get("user_id")) or connection.user_id
        identity = ChannelIdentity(
            channel_id=self.channel_id,
            kind=self.kind,
            account_id=self.account_id,
            peer_id=connection.peer_id,
            thread_id=thread_id,
            peer_type="terminal",
            user_id=user_id,
            message_id=message_id,
        )
        inbound = InboundMessage(
            channel=self.channel_id,
            content=text,
            content_type="text",
            user_id=user_id,
            channel_identity=identity,
            metadata={
                "terminal": {
                    "peer_id": connection.peer_id,
                    "device_name": connection.device_name,
                    "capabilities": connection.capabilities,
                }
            },
        )
        accept = await self.inbound_sink.accept_inbound(inbound)
        ack: dict[str, Any] = {
            "type": "ack",
            "message_id": message_id,
            "session_id": accept.session_id or identity.session_id(),
            "accepted": accept.accepted,
        }
        if accept.duplicate:
            ack["duplicate"] = True
            ack["pending"] = accept.pending
        record = accept.record or {}
        if record.get("reply"):
            ack["reply"] = record["reply"]
        if accept.error or record.get("error"):
            ack["error"] = accept.error or record.get("error")
        await websocket.send_json(ack)

    async def send(self, message: OutboundMessage) -> None:
        session_id = message.session_id
        if not session_id and message.channel_identity is not None:
            session_id = message.channel_identity.session_id()
        connection = self._connections_by_session.get(session_id or "")
        if connection is None:
            message.metadata["delivery_status"] = "unclaimed"
            return

        payload = {
            "type": "message",
            "role": "assistant",
            "message_id": message.channel_identity.message_id if message.channel_identity else message.message_id,
            "run_id": message.run_id,
            "text": message.content,
            "finish_reason": message.finish_reason,
        }
        try:
            await connection.websocket.send_json(payload)
        except Exception:
            message.metadata["delivery_status"] = "unclaimed"
            self._remove_connection(connection)
            return

    def _remove_connection(self, connection: TerminalConnection) -> None:
        current = self._connections_by_session.get(connection.session_id)
        if current is connection:
            self._connections_by_session.pop(connection.session_id, None)
        if self._session_by_peer.get(connection.peer_id) == connection.session_id:
            self._session_by_peer.pop(connection.peer_id, None)

    def _record(
        self,
        *,
        kind: str,
        session_id: str | None = None,
        message_id: str | None = None,
        status: str = "ok",
        error: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> None:
        if self.event_recorder is None:
            return
        self.event_recorder(
            channel_id=self.channel_id,
            kind=kind,
            session_id=session_id,
            message_id=message_id,
            status=status,
            error=error,
            metadata=metadata,
        )
```

- [ ] **Step 2: Export the adapter**

Modify `app-instance/backend/beaver/interfaces/channels/__init__.py` so it imports and exports the adapter:

```python
from .terminal_websocket import TerminalWebSocketAdapter
```

Add `"TerminalWebSocketAdapter"` to `__all__` if this file defines `__all__`.

- [ ] **Step 3: Run import-focused tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_imports.py -q
```

Expected: pass.

- [ ] **Step 4: Commit adapter skeleton**

```bash
git add app-instance/backend/beaver/interfaces/channels/terminal_websocket.py app-instance/backend/beaver/interfaces/channels/__init__.py
git commit -m "feat: add terminal websocket channel adapter"
```

---

### Task 3: Wire Adapter Factory, Status, and WebSocket Route

**Files:**
- Modify: `app-instance/backend/beaver/interfaces/channels/runtime.py`
- Modify: `app-instance/backend/beaver/interfaces/web/app.py`
- Modify: `app-instance/nginx.conf`

- [ ] **Step 1: Add runtime event recorder and terminal adapter factory**

Modify `app-instance/backend/beaver/interfaces/channels/runtime.py`.

Add this method to `ChannelRuntime`:

```python
    def record_event(
        self,
        *,
        channel_id: str,
        kind: str,
        session_id: str | None = None,
        message_id: str | None = None,
        run_id: str | None = None,
        status: str = "ok",
        error: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> None:
        self.events.record(
            channel_id=channel_id,
            kind=kind,
            session_id=session_id,
            message_id=message_id,
            run_id=run_id,
            status=status,
            error=error,
            metadata=metadata,
        )
```

Update `_build_adapter()`:

```python
    def _build_adapter(self, channel_id: str, cfg: ChannelConfig) -> ChannelAdapter:
        if cfg.kind == "webhook" and cfg.mode == "webhook":
            from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter

            return GenericWebhookAdapter(
                channel_id=channel_id,
                kind=cfg.kind,
                mode=cfg.mode,
                account_id=cfg.account_id,
                display_name=cfg.display_name,
                inbound_sink=self,
                response_timeout_seconds=float(cfg.config.get("response_timeout_seconds") or 1800),
            )

        if cfg.kind == "terminal" and cfg.mode == "websocket":
            from beaver.interfaces.channels.terminal_websocket import TerminalWebSocketAdapter

            return TerminalWebSocketAdapter(
                channel_id=channel_id,
                kind=cfg.kind,
                mode=cfg.mode,
                account_id=cfg.account_id,
                display_name=cfg.display_name,
                inbound_sink=self,
                event_recorder=self.record_event,
                heartbeat_seconds=float(cfg.config.get("heartbeat_seconds") or 30),
                max_message_chars=int(cfg.config.get("max_message_chars") or 20000),
            )

        raise ValueError(f"Unsupported channel kind/mode: {cfg.kind}/{cfg.mode}")
```

Update `statuses()` so terminal channels expose capabilities, URL, and connected peer count:

```python
            capabilities = []
            webhook_url = None
            websocket_url = None
            connected_peers = 0
            if cfg.kind == "webhook":
                capabilities = ["receive_text", "send_text", "sync_webhook_response"]
                webhook_url = f"/api/channels/{channel_id}/webhook"
            elif cfg.kind == "terminal" and cfg.mode == "websocket":
                capabilities = ["receive_text", "send_text", "persistent_connection"]
                websocket_url = f"/api/channels/{channel_id}/ws"
                adapter = self.adapters.get(channel_id)
                if adapter is not None and hasattr(adapter, "status_extra"):
                    extra = adapter.status_extra()  # type: ignore[attr-defined]
                    connected_peers = int(extra.get("connected_peers") or 0)
```

Include these fields in the status item:

```python
                    "webhook_url": webhook_url,
                    "websocket_url": websocket_url,
                    "connected_peers": connected_peers,
```

- [ ] **Step 2: Add the FastAPI channel websocket route**

Modify `app-instance/backend/beaver/interfaces/web/app.py` near the existing `/api/channels/{channel_id}/webhook` route:

```python
    @app.websocket("/api/channels/{channel_id}/ws")
    async def channel_websocket(websocket: WebSocket, channel_id: str) -> None:
        runtime = getattr(websocket.app.state, "channel_runtime", None)
        if not isinstance(runtime, ChannelRuntime):
            await websocket.accept()
            await websocket.send_json({"type": "error", "error": "Channel runtime is not running"})
            await websocket.close(code=1011)
            return
        adapter = runtime.adapters.get(channel_id)
        if adapter is None or not hasattr(adapter, "handle_websocket"):
            await websocket.accept()
            await websocket.send_json({"type": "error", "error": "WebSocket channel not found"})
            await websocket.close(code=1008)
            return
        await adapter.handle_websocket(websocket)  # type: ignore[attr-defined]
```

- [ ] **Step 3: Add WebSocket upgrade headers to nginx channel location**

Modify `app-instance/nginx.conf`:

```nginx
        location /api/channels/ {
            proxy_pass http://127.0.0.1:18080;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_read_timeout 3600;
            proxy_send_timeout 3600;
        }
```

- [ ] **Step 4: Run the roundtrip test**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_terminal_websocket_channel.py::test_terminal_websocket_connect_ping_and_message_roundtrip -q
```

Expected: pass.

- [ ] **Step 5: Run channel runtime tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_channel_runtime.py tests/unit/test_gateway_channels.py tests/unit/test_terminal_websocket_channel.py -q
```

Expected: pass.

- [ ] **Step 6: Commit route and runtime wiring**

```bash
git add app-instance/backend/beaver/interfaces/channels/runtime.py app-instance/backend/beaver/interfaces/web/app.py app-instance/nginx.conf app-instance/backend/tests/unit/test_terminal_websocket_channel.py
git commit -m "feat: wire terminal websocket channel"
```

---

### Task 4: Add Edge Case Tests and Finish Protocol Semantics

**Files:**
- Modify: `app-instance/backend/tests/unit/test_terminal_websocket_channel.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/terminal_websocket.py`

- [ ] **Step 1: Add tests for pre-connect, unknown frame, and validation errors**

Append to `app-instance/backend/tests/unit/test_terminal_websocket_channel.py`:

```python
def test_terminal_websocket_rejects_message_before_connect(tmp_path: Path) -> None:
    config_path = write_terminal_config(tmp_path)
    service = TerminalFakeAgentService(config_path=config_path)
    app = create_app(service=service, manage_service_lifecycle=False)

    with TestClient(app) as client:
        with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
            websocket.send_json({"type": "message", "message_id": "m1", "text": "hello"})
            assert websocket.receive_json() == {
                "type": "error",
                "error": "connect is required before message",
            }
            websocket.send_json({"type": "ping"})
            assert websocket.receive_json() == {"type": "pong"}

    service.close()
    assert service.inbound_calls == []


def test_terminal_websocket_unknown_frame_keeps_connection_open(tmp_path: Path) -> None:
    config_path = write_terminal_config(tmp_path)
    service = TerminalFakeAgentService(config_path=config_path)
    app = create_app(service=service, manage_service_lifecycle=False)

    with TestClient(app) as client:
        with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
            websocket.send_json({"type": "example"})
            assert websocket.receive_json() == {
                "type": "error",
                "error": "Unsupported websocket frame type: example",
            }
            websocket.send_json({"type": "ping"})
            assert websocket.receive_json() == {"type": "pong"}

    service.close()


def test_terminal_websocket_validates_message_fields(tmp_path: Path) -> None:
    config_path = write_terminal_config(tmp_path)
    service = TerminalFakeAgentService(config_path=config_path)
    app = create_app(service=service, manage_service_lifecycle=False)

    with TestClient(app) as client:
        with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
            websocket.send_json({"type": "connect", "peer_id": "device-001"})
            assert websocket.receive_json()["type"] == "connected"

            websocket.send_json({"type": "message", "text": "hello"})
            assert websocket.receive_json() == {"type": "error", "error": "message_id is required"}

            websocket.send_json({"type": "message", "message_id": "m1", "text": "   "})
            assert websocket.receive_json() == {"type": "error", "error": "text is required"}

    service.close()
    assert service.inbound_calls == []
```

- [ ] **Step 2: Add duplicate message test**

Append:

```python
def test_terminal_websocket_duplicate_message_returns_cached_reply(tmp_path: Path) -> None:
    config_path = write_terminal_config(tmp_path)
    service = TerminalFakeAgentService(config_path=config_path)
    app = create_app(service=service, manage_service_lifecycle=False)

    with TestClient(app) as client:
        with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
            websocket.send_json({"type": "connect", "peer_id": "device-001"})
            assert websocket.receive_json()["type"] == "connected"

            frame = {"type": "message", "message_id": "device-001-000001", "text": "hello"}
            websocket.send_json(frame)
            assert websocket.receive_json()["accepted"] is True
            assert websocket.receive_json()["text"] == "echo:hello"

            websocket.send_json(frame)
            duplicate = websocket.receive_json()

    service.close()
    assert duplicate["type"] == "ack"
    assert duplicate["accepted"] is False
    assert duplicate["duplicate"] is True
    assert duplicate["pending"] is False
    assert duplicate["reply"] == "echo:hello"
    assert len(service.inbound_calls) == 1
```

- [ ] **Step 3: Add disconnect/unclaimed test**

Append:

```python
def test_terminal_websocket_disconnect_before_reply_records_unclaimed(tmp_path: Path) -> None:
    config_path = write_terminal_config(tmp_path)
    service = TerminalFakeAgentService(config_path=config_path, delay_seconds=0.05)
    app = create_app(service=service, manage_service_lifecycle=False)

    with TestClient(app) as client:
        with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
            websocket.send_json({"type": "connect", "peer_id": "device-001"})
            assert websocket.receive_json()["type"] == "connected"
            websocket.send_json({"type": "message", "message_id": "device-001-000001", "text": "slow"})
            assert websocket.receive_json()["accepted"] is True

        # Allow the runtime bridge to finish after the socket has closed.
        import time

        time.sleep(0.15)
        events = client.get("/api/channels/terminal-dev/events").json()

    service.close()
    kinds = [event["kind"] for event in events]
    assert "terminal_disconnected" in kinds
    assert "outbound_unclaimed" in kinds
```

- [ ] **Step 4: Add status exposure test**

Append:

```python
def test_terminal_channel_status_exposes_websocket_url_and_peer_count(tmp_path: Path) -> None:
    config_path = write_terminal_config(tmp_path)
    service = TerminalFakeAgentService(config_path=config_path)
    app = create_app(service=service, manage_service_lifecycle=False)

    with TestClient(app) as client:
        initial = client.get("/api/status").json()["channels"][0]
        assert initial["channel_id"] == "terminal-dev"
        assert initial["websocket_url"] == "/api/channels/terminal-dev/ws"
        assert initial["connected_peers"] == 0
        assert "persistent_connection" in initial["capabilities"]

        with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
            websocket.send_json({"type": "connect", "peer_id": "device-001"})
            assert websocket.receive_json()["type"] == "connected"
            connected = client.get("/api/status").json()["channels"][0]
            assert connected["connected_peers"] == 1

    service.close()
```

- [ ] **Step 5: Run edge case tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_terminal_websocket_channel.py -q
```

Expected: pass. If any test fails, adjust `TerminalWebSocketAdapter` only; do not bypass the runtime path.

- [ ] **Step 6: Commit edge case coverage**

```bash
git add app-instance/backend/tests/unit/test_terminal_websocket_channel.py app-instance/backend/beaver/interfaces/channels/terminal_websocket.py
git commit -m "test: cover terminal websocket edge cases"
```

---

### Task 5: Expose Terminal Status in Frontend Types and Dialog

**Files:**
- Modify: `app-instance/frontend/types/index.ts`
- Modify: `app-instance/frontend/app/(app)/status/page.tsx`

- [ ] **Step 1: Extend `ChannelStatus`**

Modify `app-instance/frontend/types/index.ts`:

```ts
export interface ChannelStatus {
  channel_id: string;
  name?: string;
  kind: string;
  mode: string;
  display_name: string;
  enabled: boolean;
  state: 'configured' | 'disabled' | 'starting' | 'running' | 'degraded' | 'error' | 'stopped';
  account_id: string;
  last_error?: string | null;
  last_event_at?: string | null;
  started_at?: string | null;
  capabilities: string[];
  webhook_url?: string | null;
  websocket_url?: string | null;
  connected_peers?: number;
}
```

- [ ] **Step 2: Show WebSocket details in the channel dialog**

Modify the selected channel `InfoRow` block in `app-instance/frontend/app/(app)/status/page.tsx`:

```tsx
<InfoRow label="State" value={selectedChannel.state} />
<InfoRow label="Account" value={selectedChannel.account_id || '-'} />
<InfoRow label="Webhook" value={selectedChannel.webhook_url || '-'} />
<InfoRow label="WebSocket" value={selectedChannel.websocket_url || '-'} />
<InfoRow label="Connected peers" value={String(selectedChannel.connected_peers ?? 0)} />
<InfoRow label="Last error" value={selectedChannel.last_error || '-'} />
```

- [ ] **Step 3: Show connected peer count in compact channel rows**

In the channel row subtitle, replace:

```tsx
{ch.channel_id} · {ch.kind}/{ch.mode} · {ch.account_id}
```

with:

```tsx
{ch.channel_id} · {ch.kind}/{ch.mode} · {ch.account_id}
{typeof ch.connected_peers === 'number' ? ` · ${ch.connected_peers} peer${ch.connected_peers === 1 ? '' : 's'}` : ''}
```

- [ ] **Step 4: Run frontend verification**

Run:

```bash
cd app-instance/frontend
npm run typecheck
npm test -- --run
```

Expected: both commands pass.

- [ ] **Step 5: Commit frontend status updates**

```bash
git add app-instance/frontend/types/index.ts 'app-instance/frontend/app/(app)/status/page.tsx'
git commit -m "feat: show terminal websocket channel status"
```

---

### Task 6: Final Verification and Manual Smoke Notes

**Files:**
- No code files unless verification exposes a defect.

- [ ] **Step 1: Run backend terminal/channel tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_terminal_websocket_channel.py tests/unit/test_channel_runtime.py tests/unit/test_gateway_channels.py tests/unit/test_config_loader.py tests/unit/test_imports.py -q
```

Expected: pass.

- [ ] **Step 2: Run full backend unit tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit -q
```

Expected: pass.

- [ ] **Step 3: Run frontend verification**

Run:

```bash
cd app-instance/frontend
npm run typecheck
npm test -- --run
```

Expected: pass.

- [ ] **Step 4: Check nginx syntax if nginx is installed**

Run:

```bash
nginx -t -c /home/ivan/xuan/beaver_project/app-instance/nginx.conf
```

Expected when nginx is installed: syntax is ok. If the command is missing, record that nginx is not installed and do not claim nginx syntax was verified.

- [ ] **Step 5: Check for generated runtime state**

Run:

```bash
find app-instance/backend/state -maxdepth 3 -type f -print 2>/dev/null || true
```

Expected: no test-generated state files remain. Remove only generated test state files if they appear under `app-instance/backend/state`.

- [ ] **Step 6: Check diff hygiene**

Run:

```bash
git diff --check
git status --short
```

Expected: `git diff --check` passes. `git status --short` shows only intentional terminal websocket/channel runtime changes.

- [ ] **Step 7: Record manual smoke command for terminal colleagues**

Use this command after a local server is running:

```bash
websocat ws://127.0.0.1:8080/api/channels/terminal-dev/ws
```

Paste:

```json
{"type":"connect","peer_id":"device-001","device_name":"desk-terminal","capabilities":["text"]}
```

Expected:

```json
{"type":"connected","channel_id":"terminal-dev","session_id":"terminal-dev:local:device-001"}
```

Paste:

```json
{"type":"message","message_id":"device-001-000001","text":"hello"}
```

Expected:

```json
{"type":"ack","message_id":"device-001-000001","session_id":"terminal-dev:local:device-001","accepted":true}
```

Then expect a final assistant message:

```json
{"type":"message","role":"assistant","message_id":"device-001-000001","run_id":"...","text":"...","finish_reason":"stop"}
```

- [ ] **Step 8: Final commit if needed**

If Tasks 1-5 were not committed separately, make one scoped commit:

```bash
git add app-instance/backend/beaver/interfaces/channels/terminal_websocket.py \
  app-instance/backend/beaver/interfaces/channels/__init__.py \
  app-instance/backend/beaver/interfaces/channels/runtime.py \
  app-instance/backend/beaver/interfaces/web/app.py \
  app-instance/backend/tests/unit/test_terminal_websocket_channel.py \
  app-instance/frontend/types/index.ts \
  'app-instance/frontend/app/(app)/status/page.tsx' \
  app-instance/nginx.conf
git commit -m "feat: add terminal websocket channel"
```

Do not commit unrelated user changes.

---

## Spec Coverage Checklist

- Text-only WebSocket endpoint: Tasks 2 and 3.
- Bus-first routing through `ChannelRuntime.accept_inbound()`: Tasks 1, 2, and 3.
- Connect/connected/message/ack/assistant frames: Tasks 1 and 2.
- Ping/pong and error frames: Tasks 1 and 4.
- Stable session id from `peer_id`: Tasks 1 and 2.
- Dedupe behavior: Task 4.
- Disconnect/unclaimed behavior: Task 4.
- Status fields and events: Tasks 3, 4, and 5.
- Nginx WebSocket upgrade: Task 3.
- Verification and manual smoke command: Task 6.
