Now I have all the context I need. Let me produce the section content.

# Section 4: Python Bridge Layer -- Protocol and Connection

## Overview

This section creates the Python-side bridge package with two core modules: **protocol.py** (message dataclasses and JSON encoding/decoding) and **connection.py** (async WebSocket client). These modules form the foundation that all bridge tools depend on.

This section has **no dependencies** on other sections and can be implemented first. Sections 05 (session manager) and 06 (bridge tools) depend on this section.

## Files to Create

| File | Purpose |
|------|---------|
| `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/bridge/__init__.py` | Package docstring |
| `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/bridge/protocol.py` | Frozen dataclasses + JSON encoding/parsing |
| `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/bridge/connection.py` | Async WebSocket client |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/__init__.py` | Test package init |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_protocol.py` | Protocol encode/decode tests |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_connection.py` | Connection unit tests |

## Constraints

- **DO NOT TOUCH** any existing Phase 1 files (parsers, tools/code, tools/spatial, tools/animation, tools/assets, tools/memory, resolvers, index.py, project_index.py, session.py, existing tests).
- Only ADD new files.
- The `websockets` dependency is added in Section 05 (pyproject.toml). This section imports `websockets` lazily inside `connect()` to avoid import-time failures before the dependency is installed.

---

## Tests First

All tests use `pytest` with `asyncio_mode = "auto"`, class-based organization, and `unittest.mock.AsyncMock` for async mocking. No real WebSocket connections are made.

### `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/__init__.py`

Empty file (package init).

### `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_protocol.py`

```python
"""Tests for bridge protocol dataclasses and message parsing."""

from __future__ import annotations

import json

import pytest

from godotiq.bridge.protocol import (
    BridgeRequest,
    BridgeResponse,
    BridgeEvent,
    parse_message,
    get_timeout,
    TOOL_TIMEOUTS,
)


class TestBridgeRequest:
    """BridgeRequest serialization."""

    def test_to_json_no_params(self) -> None:
        """to_json() with no params produces valid JSON with empty params dict."""
        req = BridgeRequest(id="1", method="ping", params={})
        raw = req.to_json()
        parsed = json.loads(raw)
        assert parsed["id"] == "1"
        assert parsed["method"] == "ping"
        assert parsed["params"] == {}

    def test_to_json_with_params(self) -> None:
        """to_json() with params preserves all param values."""
        req = BridgeRequest(id="2", method="screenshot", params={"scale": 0.5, "format": "webp"})
        raw = req.to_json()
        parsed = json.loads(raw)
        assert parsed["params"]["scale"] == 0.5
        assert parsed["params"]["format"] == "webp"


class TestParseMessage:
    """parse_message() routing and error handling."""

    def test_parse_ok_response(self) -> None:
        """Parse ok response extracts id, status, result correctly."""
        text = json.dumps({"id": "1", "status": "ok", "result": {"version": "4.3"}})
        msg = parse_message(text)
        assert isinstance(msg, BridgeResponse)
        assert msg.id == "1"
        assert msg.status == "ok"
        assert msg.result == {"version": "4.3"}
        assert msg.is_ok

    def test_parse_error_response(self) -> None:
        """Parse error response extracts error code and message."""
        text = json.dumps({
            "id": "2",
            "status": "error",
            "error": {"code": "GAME_NOT_RUNNING", "message": "Start the game first"},
        })
        msg = parse_message(text)
        assert isinstance(msg, BridgeResponse)
        assert not msg.is_ok
        assert msg.error_code == "GAME_NOT_RUNNING"
        assert msg.error_message == "Start the game first"

    def test_parse_event(self) -> None:
        """Parse event message returns BridgeEvent with event name and data."""
        text = json.dumps({"event": "game_started", "data": {}})
        msg = parse_message(text)
        assert isinstance(msg, BridgeEvent)
        assert msg.event == "game_started"

    def test_parse_event_with_data(self) -> None:
        """Parse event with data preserves nested data fields."""
        text = json.dumps({"event": "runtime_error", "data": {"message": "null ref", "line": 42}})
        msg = parse_message(text)
        assert isinstance(msg, BridgeEvent)
        assert msg.data["message"] == "null ref"
        assert msg.data["line"] == 42

    def test_invalid_json_raises(self) -> None:
        """Invalid JSON raises ValueError with 'Invalid JSON' message."""
        with pytest.raises(ValueError, match="Invalid JSON"):
            parse_message("not json {{{")

    def test_missing_id_and_event_raises(self) -> None:
        """Message without id or event raises ValueError."""
        with pytest.raises(ValueError):
            parse_message(json.dumps({"status": "ok"}))

    def test_non_object_json_raises(self) -> None:
        """Non-object JSON (string, array) raises ValueError."""
        with pytest.raises(ValueError):
            parse_message(json.dumps("just a string"))
        with pytest.raises(ValueError):
            parse_message(json.dumps([1, 2, 3]))


class TestTimeouts:
    """get_timeout() and TOOL_TIMEOUTS configuration."""

    def test_known_method_screenshot(self) -> None:
        """Known method returns configured timeout (screenshot=30)."""
        assert get_timeout("screenshot") == 30.0

    def test_known_method_ping(self) -> None:
        """Known method returns configured timeout (ping=2)."""
        assert get_timeout("ping") == 2.0

    def test_unknown_method_default(self) -> None:
        """Unknown method returns default 5.0."""
        assert get_timeout("nonexistent_method") == 5.0

    def test_all_timeouts_positive(self) -> None:
        """All configured timeouts are positive."""
        for method, timeout in TOOL_TIMEOUTS.items():
            assert timeout > 0, f"Timeout for {method} is not positive: {timeout}"
```

### `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_connection.py`

```python
"""Tests for BridgeConnection async WebSocket client."""

from __future__ import annotations

import pytest

from godotiq.bridge.connection import (
    BridgeConnection,
    BridgeConnectionError,
    BridgeError,
)


class TestBridgeConnectionInit:
    """Constructor and initial state."""

    def test_default_host_and_port(self) -> None:
        """Default host is 127.0.0.1 and port is 6007."""
        conn = BridgeConnection()
        assert conn._host == "127.0.0.1"
        assert conn._port == 6007

    def test_custom_host_and_port(self) -> None:
        """Custom host and port are stored correctly."""
        conn = BridgeConnection(host="192.168.1.1", port=9999)
        assert conn._host == "192.168.1.1"
        assert conn._port == 9999

    def test_initial_state_disconnected(self) -> None:
        """is_connected and is_game_running are False initially."""
        conn = BridgeConnection()
        assert conn.is_connected is False
        assert conn.is_game_running is False


class TestBridgeConnectionRequest:
    """Request behavior without connection."""

    async def test_request_without_connect_raises(self) -> None:
        """request() without connect() raises BridgeConnectionError."""
        conn = BridgeConnection()
        with pytest.raises(BridgeConnectionError):
            await conn.request("ping")


class TestBridgeConnectionEvents:
    """Game state tracking and event dispatch."""

    def test_on_game_started(self) -> None:
        """_on_game_started sets is_game_running to True."""
        conn = BridgeConnection()
        conn._on_game_started({})
        assert conn.is_game_running is True

    def test_on_game_stopped(self) -> None:
        """_on_game_stopped sets is_game_running to False."""
        conn = BridgeConnection()
        conn._game_running = True
        conn._on_game_stopped({})
        assert conn.is_game_running is False

    def test_event_handler_called(self) -> None:
        """Registered event handler is called with correct data."""
        conn = BridgeConnection()
        received = []
        conn.on_event("test_event", lambda data: received.append(data))
        conn._dispatch_event("test_event", {"key": "value"})
        assert len(received) == 1
        assert received[0] == {"key": "value"}

    def test_multiple_handlers_same_event(self) -> None:
        """Multiple handlers for same event are all called."""
        conn = BridgeConnection()
        results_a = []
        results_b = []
        conn.on_event("multi", lambda d: results_a.append(d))
        conn.on_event("multi", lambda d: results_b.append(d))
        conn._dispatch_event("multi", {"x": 1})
        assert len(results_a) == 1
        assert len(results_b) == 1


class TestBridgeConnectionErrorTypes:
    """Custom exception attributes."""

    def test_bridge_error_attributes(self) -> None:
        """BridgeError stores code and error_message attributes."""
        err = BridgeError("GAME_NOT_RUNNING", "Start the game first")
        assert err.code == "GAME_NOT_RUNNING"
        assert err.error_message == "Start the game first"

    def test_bridge_error_str_includes_code(self) -> None:
        """BridgeError str includes the error code."""
        err = BridgeError("TIMEOUT", "Request timed out")
        assert "TIMEOUT" in str(err)
```

---

## Implementation Details

### `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/bridge/__init__.py`

```python
"""GodotIQ bridge layer — WebSocket communication with the Godot editor addon."""
```

### `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/bridge/protocol.py`

This module defines three frozen dataclasses and utility functions for the JSON wire protocol.

**Dataclasses (all frozen):**

- `BridgeRequest(id: str, method: str, params: dict)` -- Represents an outgoing request. Has a `to_json() -> str` method that serializes to the JSON wire format: `{"id": "...", "method": "...", "params": {...}}`.

- `BridgeResponse(id: str, status: str, result: dict | None, error: dict | None)` -- Represents a response from the addon. Properties:
  - `is_ok -> bool`: Returns `self.status == "ok"`
  - `error_code -> str`: Returns `self.error.get("code", "UNKNOWN")` if error exists, else `""`
  - `error_message -> str`: Returns `self.error.get("message", "")` if error exists, else `""`

- `BridgeEvent(event: str, data: dict)` -- Represents a push event from the addon (game_started, game_stopped, runtime_error).

**`parse_message(text: str) -> BridgeResponse | BridgeEvent`:**
1. Parse `text` as JSON. Catch `json.JSONDecodeError` and raise `ValueError("Invalid JSON: ...")`.
2. Verify the result is a `dict`. If not, raise `ValueError`.
3. Route by key presence:
   - If `"id"` key present: construct and return a `BridgeResponse`
   - If `"event"` key present: construct and return a `BridgeEvent` (with `data` defaulting to `{}`)
   - Neither: raise `ValueError("Message must contain 'id' or 'event'")`

**Timeout configuration:**

```python
TOOL_TIMEOUTS: dict[str, float] = {
    "ping": 2.0,
    "screenshot": 30.0,
    "perf_snapshot": 5.0,
    "editor_context": 5.0,
    "run": 10.0,
    "stop": 5.0,
}
```

`get_timeout(method: str) -> float` -- Returns `TOOL_TIMEOUTS.get(method, 5.0)`.

**Screenshot retry constants:**

```python
SCREENSHOT_MAX_RETRIES: int = 3
SCREENSHOT_RETRY_DELAY: float = 0.5
```

### `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/bridge/connection.py`

This module implements the async WebSocket client that talks to the Godot addon.

**Exceptions (module-level, all inherit from `Exception`):**

- `BridgeConnectionError` -- Raised when the bridge is not connected or connection fails.
- `BridgeTimeoutError` -- Raised when a request times out waiting for a response.
- `BridgeError(code: str, error_message: str)` -- Raised when the addon returns an error response. Stores `code` and `error_message` as attributes. `__str__` includes the code, e.g., `f"[{self.code}] {self.error_message}"`.

**`BridgeConnection` class:**

Constructor signature: `__init__(self, host: str = "127.0.0.1", port: int = 6007)`

Internal state:
- `_host: str`, `_port: int` -- Connection target
- `_ws` -- WebSocket connection object (None when disconnected)
- `_pending: dict[str, asyncio.Future]` -- Maps request IDs to futures awaiting responses
- `_event_handlers: dict[str, list[Callable]]` -- Maps event names to handler lists
- `_request_counter: int` -- Auto-incrementing request ID counter
- `_connected: bool` -- Whether currently connected (exposed as `is_connected` property)
- `_game_running: bool` -- Whether the game is running (exposed as `is_game_running` property)
- `_listen_task: asyncio.Task | None` -- Background listener task

**Properties:**
- `is_connected -> bool`: Returns `self._connected`
- `is_game_running -> bool`: Returns `self._game_running`

**Key methods:**

`async connect(self, timeout: float = 5.0) -> None`:
- Imports `websockets` lazily (inside this method, not at module top level) to avoid import errors before the dependency is installed
- Creates connection to `ws://{host}:{port}` with `max_size=16*1024*1024` (16 MB) and the given timeout
- Starts `_listen_loop` as a background `asyncio.Task`
- Registers internal event handlers for `"game_started"` and `"game_stopped"`
- Sets `_connected = True`
- Wraps connection errors as `BridgeConnectionError`

`async disconnect(self) -> None`:
- Cancels `_listen_task` if running
- Closes `_ws` if open
- Fails all pending futures with `BridgeConnectionError("Disconnected")`
- Clears `_pending`, sets `_connected = False`

`async request(self, method: str, params: dict | None = None, timeout: float | None = None) -> dict`:
- If not connected, raises `BridgeConnectionError("Not connected...")`
- Increments `_request_counter`, creates `BridgeRequest` with string ID
- Creates an `asyncio.Future`, stores in `_pending[id]`
- Sends `request.to_json()` over WebSocket
- Awaits future with `asyncio.wait_for` using `get_timeout(method)` if timeout is None
- On timeout: removes from `_pending`, raises `BridgeTimeoutError`
- On response: if `response.is_ok`, returns `response.result`; else raises `BridgeError(response.error_code, response.error_message)`

`async request_with_retry(self, method: str, params: dict | None = None, max_retries: int = 3, retry_delay: float = 0.5) -> dict`:
- Loops up to `max_retries` attempts calling `self.request(method, params)`
- On `BridgeTimeoutError` or `BridgeError`: if retries remain, `await asyncio.sleep(retry_delay)` and retry; else re-raise
- `BridgeConnectionError` is NOT retried (re-raised immediately)

`def on_event(self, event: str, handler: Callable) -> None`:
- Appends handler to `_event_handlers[event]` list (creates list if first handler)

`def _dispatch_event(self, event: str, data: dict) -> None`:
- Calls all registered handlers for the given event name with the data dict
- Used by `_listen_loop` and also directly by tests

`def _on_game_started(self, data: dict) -> None`:
- Sets `self._game_running = True`

`def _on_game_stopped(self, data: dict) -> None`:
- Sets `self._game_running = False`

`async _listen_loop(self) -> None`:
- Background task that iterates over incoming WebSocket messages using `async for message in self._ws:`
- Parses each message with `parse_message()`
- If `BridgeResponse`: resolves the matching future in `_pending` (if ID found)
- If `BridgeEvent`: calls `_dispatch_event(event.event, event.data)`
- On `websockets.ConnectionClosed` or other exception: fails all pending futures with `BridgeConnectionError`, sets `_connected = False`
- Uses try/except around the entire loop so connection loss is handled gracefully

---

## Verification

After implementation, run:

```bash
cd /Users/salvatorefarruggio/Desktop/godotiq
pytest tests/test_bridge/test_protocol.py tests/test_bridge/test_connection.py -v
```

All tests should pass. Additionally verify imports work:

```bash
python -c "from godotiq.bridge.protocol import parse_message, BridgeRequest; print('protocol OK')"
python -c "from godotiq.bridge.connection import BridgeConnection, BridgeConnectionError; print('connection OK')"
```

Existing Phase 1 tests must continue to pass:

```bash
pytest tests/ -v
```

---

## Post-Implementation Notes

### Files Created
- `src/godotiq/bridge/__init__.py` — package docstring
- `src/godotiq/bridge/protocol.py` — 3 frozen dataclasses, parse_message(), timeout config (95 lines)
- `src/godotiq/bridge/connection.py` — BridgeConnection async client with 3 exception types (205 lines)
- `tests/test_bridge/__init__.py` — empty init
- `tests/test_bridge/test_protocol.py` — 13 tests
- `tests/test_bridge/test_connection.py` — 10 tests

### Test Results
- 23 new tests, all passing
- 483 total tests (460 existing + 23 new)

### Deviations from Plan

1. **Reconnect handler safety**: connect() now clears internal game_started/game_stopped handlers before re-registering to prevent duplicate accumulation on reconnect.

2. **send() failure cleanup**: request() wraps ws.send() in try/except to clean up the pending future and raise BridgeConnectionError instead of leaving a dangling future.

3. **disconnect() resets _game_running**: Added _game_running=False to disconnect() for consistent state after disconnection.