chore(agent): phase roadmap from TODO.md, 3 phases

The one TODO item — a /kiosk endpoint whose state (fullscreen, which
mirror) any client can manipulate — becomes three phases, since it is
three workstreams:

- 07_kiosk_layout_state: the state on the server. `arrange` in, `layout`
  out (staged/fullscreen/followers), Registry policy with a stale-staged
  fallback on teardown, `GET /api/kiosk` for curl-level debugging.
- 08_kiosk_page: the wall made reusable (`static/wall.js`, behaviour-
  preserving) and the chrome-free /kiosk page that arranges itself from
  the state and publishes a local keypress back.
- 09_wall_remote_page: /remote, the controller you open on a phone —
  shows no media and subscribes to nothing, proven by /api/streams'
  watcher count — plus the security line and the kiosk box's new URL.

Locked anchors hold: no third role, state stays in memory (A7), Registry
stays pure (A8), no external assets (A10), no remote audio command
(SECURITY.md §4). PLAN.md untouched, per the user's decision, so its §7
roadmap table does not list 07-09.

LLM-Generated: true
This commit is contained in:
2026-11-03 09:15:12 -05:00
parent 3f93481c54
commit 1f60a6b40c
14 changed files with 1526 additions and 0 deletions
@@ -0,0 +1,113 @@
# Phase 07 — Shared presentation state on the server
**Source:** `TODO.md` L3 — "Add a /kiosk endpoint that is controllable by other clients… There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately…" (the server half of that item)
**Story:** n/a
**Context:** `.agents/PLAN.md` §2 A5 (one WebSocket, JSON, verb in / noun out), A6 (camelCase on the wire), A7 (in-memory state only — no database, no persistence), A8 (`Registry` stays pure and returns `list[Envelope]`), A11/A19 (no accounts; the address is the gate); `src/mirror/protocol.py`, `src/mirror/registry.py`, `src/mirror/app.py`; `tests/test_registry.py`, `tests/test_protocol.py`, `tests/test_app.py`
## Objective
Give the server one piece of presentation state — **which mirror is on stage, and
whether it should be fullscreen** — that any connected peer may change and that is
pushed to everyone the moment it changes. This is the piece the TODO item is
actually short of: `/watch` already stages and fullscreens locally (phase 01), but
that decision lives in one tab and dies there, so an unattended display can never
be driven from elsewhere.
This phase is **server-side only**: no new page, no browser-visible behaviour yet.
It leaves the app exactly as usable as it was, with a wire format and a
`GET /api/kiosk` read surface that phases 08 and 09 build their clients on.
## The contract this phase fixes (phases 08/09 code against it verbatim)
Inbound — verb, like `publish`/`subscribe` (PLAN A5), from **any** peer:
```json
{"type":"arrange","staged":"p1ab2c3d","fullscreen":true}
```
* `staged`: a stream id, or `null` for "back to the wall", or **absent** for "no
opinion". Absent and explicit-`null` are different requests, which is why the
model is read with pydantic's `model_fields_set` rather than with defaults.
* `fullscreen`: `bool`, absent = no opinion.
* A `staged` value that does not name a live mirror is refused with the existing
`unknown_stream` error and changes nothing.
* No audio/mute field. `SECURITY.md` §4: sound must trace back to a gesture on the
device that plays it — a remote hand cannot unmute a stranger's display.
Outbound — noun, to every viewer and back to the sender:
```json
{"type":"layout","staged":"p1ab2c3d","fullscreen":true,"followers":1}
```
* `followers` is how many connected peers announced `hello{"kiosk":true}`, i.e.
how many displays are obeying this. A controller is a plain viewer and is not
counted, which is what makes "nobody is showing a wall" distinguishable from
"the wall will follow".
* Emitted on: any accepted change, a kiosk display joining or leaving, and the
stale fallback when the staged mirror disappears.
* `welcome` carries the same object as its `layout` field, so a page is arranged
before it has asked for anything — the TODO's "navigate to it and it arranges
itself" depends on this.
`GET /api/kiosk` answers the same object, so the state is debuggable with `curl`
exactly like `/api/streams` (AGENTS.md §8).
## What is deliberately *not* here
* **No third role.** `Role` stays `sharer | viewer`; a kiosk is a viewer that
happens to obey the layout. The signal-authorisation matrix and A4 are
untouched.
* **No media coupling.** Staging never subscribes, never negotiates, never touches
an `RTCPeerConnection` — the kiosk already subscribes to every mirror it can
show, so "which one is on stage" is pure presentation.
* **One state per server process.** Not per display, not per room. Two `/kiosk`
pages follow the same state; that is the product's single-wall model (A7).
* **No persistence.** Restart the server and the wall is a wall. Consistent with
A7's reasoning: state that outlives the peers is a lie.
* **No auto-stage-newest.** The state says what is on stage; nothing here invents
a value. (`PLAN.md` §7 item 2, "Wall UX", is where auto-stage belongs.)
## Dependencies
- `06_sharer_quality_presets` (complete) — the current wall/stage/keyboard
behaviour and the green four-gate baseline this phase must not disturb.
(`.agents/phases/todo/` is empty, so this is the phase directly before 07.)
## Tasks
1. `01_arrange_and_layout_frames.md` — the two message shapes in `protocol.py`
(`Arrange` inbound, `layout()` outbound builder) plus the `kiosk` flag on
`hello`, with parse/round-trip tests.
2. `02_registry_layout_policy.md` — `Layout` state, `Registry.set_layout()` and
`Registry.layout_dto()`, the fan-out set, the follower count, and the
stale-staged fallback on teardown; unit-tested with no I/O.
3. `03_dispatch_read_endpoint_and_wire_tests.md` — `Hub._dispatch` handles
`arrange`, `GET /api/kiosk` serves the state, and the whole path is covered
over a real `TestClient` WebSocket.
## Testing & Quality
- **Unit (`tests/test_registry.py`)** owns the policy: partial update vs explicit
clear, refusal of an unknown stream, fan-out to viewers plus the sender,
follower counting, the fallback when the staged mirror goes away, and `welcome`
carrying the state.
- **Wire (`tests/test_app.py`, `tests/test_protocol.py`)** owns the camelCase
round-trip and the socket semantics: an `arrange` frame from a sharer, from a
viewer, with a bad stream id (error frame, socket stays open), and the shape +
`no-store` of `/api/kiosk` behind the address gate.
- Coverage: **>90%** on new/modified code (the project sits ~95%; `registry.py` is
the product and must stay near 100%).
- All four gates from `AGENTS.md` §4, including `-m e2e` and the `realdisplay`
suite: this phase changes frames every browser test consumes, so "nothing in the
browser changed" is itself the assertion.
## Completion Criteria
- [ ] `uv run pytest tests/test_registry.py tests/test_protocol.py tests/test_app.py` green, including the new layout cases
- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` answers `{"staged": null, "fullscreen": false, "followers": 0}` on an idle server, and reflects an `arrange` frame sent with `websocat`
- [ ] `welcome` carries a `layout` object; a second viewer connecting after an `arrange` receives the same state without being asked
- [ ] the staged mirror going away (unpublish, disconnect, eviction) resets `staged` to `null` and pushes one `layout` frame — never a stale id
- [ ] no page, no stylesheet and no browser test changed behaviour (`uv run pytest -m e2e` green with the existing assertions untouched)
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] `PLAN.md` untouched (no anchor moved: A5/A7/A8/A11 all hold, see above)
@@ -0,0 +1,92 @@
# Task 01 — The `arrange` / `layout` frames
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
**Story:** n/a
## Objective
Put both halves of the presentation-state message on the wire: an inbound
`arrange` verb, an outbound `layout` noun, and a `kiosk` flag on `hello` so the
server can tell a display from a controller. No behaviour yet — `Registry` is
wired in task 02, the socket in task 03.
## Work
1. `src/mirror/protocol.py` — add the inbound verb after `Subscribe`/`Unsubscribe`,
in the `_Inbound` house style. Both fields are `| None` **and** optional, because
"leave what I did not mention alone" and "clear it" are different requests:
```python
class Arrange(_Inbound):
"""Ask the wall to show a given mirror. Absent fields are left alone;
``staged: null`` explicitly returns the displays to the wall. The caller
does not have to be the display's operator — see ``SECURITY.md`` §2.
"""
type: Literal["arrange"]
staged: str | None = None
fullscreen: bool | None = None
```
Add `Arrange` to the `ClientMessage` discriminated union and re-export nothing
else — the union is what `Hub._dispatch` switches on.
2. `src/mirror/protocol.py` — `Hello` gains one field, single word so there is no
camelCase alias to get wrong (but round-trip it anyway, A6):
```python
#: true when this peer is a wall display that obeys the shared layout.
kiosk: bool = False
```
Keep `Role = Literal["sharer", "viewer"]` exactly as it is: a kiosk is a viewer
with an attribute, not a third role (see the phase overview).
3. `src/mirror/protocol.py` — add the outbound builder beside `welcome()`:
```python
def layout(
*, staged: str | None, fullscreen: bool, followers: int
) -> dict[str, Any]:
"""The wall's presentation state, pushed to every viewer as it changes."""
return {"type": "layout", "staged": staged, "fullscreen": fullscreen, "followers": followers}
```
Key order and spelling are the contract in `00_phase.md`; the outbound builders
are hand-written dicts, so there is no alias generator to catch a typo here —
the round-trip test in this task is what catches it.
4. `src/mirror/protocol.py` — `welcome()` gains a `layout` parameter and a
`"layout"` key so a page arrives already arranged. Give it a keyword-only
default of `{"staged": None, "fullscreen": False, "followers": 0}`? **No** —
a mutable default is a shared dict. Take it as a required keyword; the one
caller (`Registry.join`) is updated in task 02, so this task must not leave a
call site broken: update `registry.py`'s `welcome(...)` call in the same commit
to pass `layout=protocol.layout(staged=None, fullscreen=False, followers=0)`
as a placeholder that task 02 replaces with the real state.
5. Do **not** touch `Hub`, `create_app`, `Registry` policy or any JS in this task.
## Testing & Quality
- `tests/test_protocol.py` — extend the message-parsing suite:
* `{"type":"arrange"}`, `{"type":"arrange","staged":null}` and
`{"type":"arrange","staged":"p1","fullscreen":true}` all parse, and
`model_fields_set` distinguishes the first two from the third (this is the
whole reason the model is written this way — test it, do not trust it).
* `hello` defaults `kiosk` to `False` and accepts `kiosk: true`.
* `protocol.layout(...)` emits exactly `type/staged/fullscreen/followers` —
assert the key set, which is how the outbound dicts are guarded elsewhere.
- `tests/test_app.py` — `test_an_unknown_message_type_is_reported_but_survivable`
must still pass, and `arrange` must *not* appear as unknown once task 03 lands;
nothing to assert here yet.
- Coverage: **>90%** on this task's new/modified code (the models and the builder
are covered directly by the parse/shape tests above).
## Completion Criteria
- [ ] `uv run pytest tests/test_protocol.py` green with the new `arrange`, `kiosk`
and `layout()` cases
- [ ] `uv run pyright` reports 0 errors (`Arrange` is in the union, so the
`isinstance` chain in `app.py` is exhaustive-checked in task 03)
- [ ] `uv run pytest -m e2e` still green with **no** test file edited — nothing a
browser does has changed
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,118 @@
# Task 02 — `Registry` owns the layout: set, validate, broadcast, fall back
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
**Story:** n/a
## Objective
The state itself, in `Registry`, with the policy that makes it trustworthy: only
live mirrors may be named, the change reaches every viewer in the same call that
produced it, and a mirror disappearing never leaves a display pointing at nothing.
Pure Python, envelopes out — A8 to the letter.
## Work
1. `src/mirror/registry.py` — a sentinel for "the caller did not mention this
field", because `None` already means "clear it":
```python
class _Unset:
"""Marker: the caller expressed no opinion, keep the current value."""
UNSET = _Unset()
```
2. `src/mirror/registry.py` — the state next to `Peer`/`Stream`, with the
dataclass house style (`slots=True`):
```python
@dataclass(slots=True)
class Layout:
"""What the wall displays. One value per server process (A7): there is
one wall, and every display obeying it is showing the same thing."""
staged: str | None = None
fullscreen: bool = False
```
`staged` holds a stream id, which is also a sharer peer id (`streamId ==
sharerId`, PLAN §3) — the fallback in step 6 relies on that identity.
3. `src/mirror/registry.py` — `Peer` gains `kiosk: bool = False`; `Registry.join()`
gains a `kiosk: bool = False` keyword and stores it. The follower count is
derived, never bookkept: `sum(1 for p in self.peers.values() if p.kiosk)`.
4. `src/mirror/registry.py` — `Registry.__init__` creates `self.layout = Layout()`;
`join()` builds its `welcome` with `layout=self.layout_dto()` (replacing task
01's placeholder), so a page is arranged before it asks.
5. `src/mirror/registry.py` — the two public operations:
* `layout_dto() -> dict[str, Any]` — `protocol.layout(staged=…, fullscreen=…,
followers=<derived count>)`. This is the only place the DTO is built, so
`/api/kiosk` (task 03) and the socket can never disagree.
* `set_layout(peer_id, *, staged: Any = UNSET, fullscreen: Any = UNSET)
-> list[Envelope]` —
- `self._peer(peer_id)` first, so a dead peer gets `unknown_peer` like every
other operation;
- `staged` given as a non-`None` string → `self._stream(staged)` (reuses the
existing `unknown_stream` code — no new error code);
- `staged` explicitly `None` → clears; absent → unchanged; same shape for
`fullscreen`;
- if nothing actually changed, return **only** the ack to the sender — no
broadcast storm from a controller that re-sends the same state;
- otherwise return the broadcast: every peer whose role is `viewer`, **plus
the sender** (a sharer that sends an `arrange` still gets its own ack),
each carrying `protocol.layout(**self.layout_dto())`. Deduplicate the
sender into the set so no peer is sent the frame twice.
6. `src/mirror/registry.py` — the fallback, in `_drop_stream()` (the single funnel
for *every* removal path: `unpublish`, disconnect, replaced stream, capacity
eviction): if `self.layout.staged == stream.id`, set it to `None` and append
the layout broadcast to the envelopes it already returns. The comment there
already explains "tell every viewer, not just subscribers" — extend it: a
display aimed at a mirror that vanished must fall back to the wall *in the same
frame set*, because there is no polling to correct it later.
7. `src/mirror/registry.py` — follower joins/leaves must also push state, or a
controller sits on a stale `followers: 0` until someone moves a mirror:
`join()` appends the layout broadcast to the other viewers' envelopes (not to
the joiner — it got the state in its `welcome`), and `leave()` appends the same
broadcast when the departing peer had `kiosk=True`.
8. `src/mirror/registry.py` module docstring — add the invariant to the bulleted
list: *the layout names either nothing or a live mirror; a mirror's removal
clears it in the same envelope set.*
9. Do **not** touch `Hub`, `app.py`, or any JS. Do not add persistence, a layout
per peer, or an audio field (see `00_phase.md`, "What is deliberately not here").
## Testing & Quality
- `tests/test_registry.py` — a new `TestLayout` class covering, in this order:
* default state is the wall, not fullscreen, zero followers;
* `set_layout` with `staged="pX"` broadcasts to viewers **and** the sender, and
no peer receives it twice;
* `staged` absent + `fullscreen=True` changes only fullscreen (the
partial-update rule, asserted on the returned DTO);
* `staged=None` clears an existing selection, and `set_layout` with no fields at
all produces no broadcast;
* an unknown stream id raises `RegistryError` with `code == "unknown_stream"`
and leaves `self.layout` untouched;
* a second `set_layout` with identical values sends the ack and nothing else;
* `unpublish`, `leave()` of the sharer, and a *replaced* stream each reset
`staged` to `None` and emit exactly one layout frame per viewer;
* `followers` counts only `kiosk=True` viewers, and a kiosk joining/leaving
pushes a layout frame whose `followers` is the new count;
* `welcome` of a peer that joins **after** an `arrange` carries that state —
this is "navigate to `/kiosk` and it arranges itself", stated at the layer
where it is decided.
Use the file's existing helpers/idioms (explicit peer ids via `id_factory`, the
`codes_and_targets` style helpers already in the module) rather than a new
harness.
- Coverage: **>90%** on this task's new/modified code — target ~100% of
`registry.py`, which the project treats as the product.
## Completion Criteria
- [ ] `uv run pytest tests/test_registry.py` green, including every `TestLayout` case above
- [ ] `uv run pyright` → 0 errors; `Registry` still imports no socket, no FastAPI, no `await` (A8, AGENTS.md §3)
- [ ] `grep -n "await\|WebSocket\|fastapi" src/mirror/registry.py` finds nothing
- [ ] a layout naming a mirror that has gone is impossible to observe: the frame
every viewer receives after a teardown says `staged: null`
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work (no wire frame any current client sends
or reads changed shape)
@@ -0,0 +1,99 @@
# Task 03 — Socket dispatch, `GET /api/kiosk`, and the wire suite
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "state … that any client can manipulate and will update immediately so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself"
**Story:** n/a
## Objective
Connect the frames to the transport and make the state readable without a
browser: `Hub` accepts `arrange` from any peer and passes the caller's *expressed*
fields to `Registry`, and `GET /api/kiosk` answers the same DTO the socket pushes.
After this task the feature is real and drivable with `websocat`, one step below
the browser.
## Work
1. `src/mirror/app.py` — `Hub._introduce`: pass the flag through,
`self.registry.join(role=hello.role, name=hello.name, device=hello.device,
kiosk=hello.kiosk)`.
2. `src/mirror/app.py` — `Hub._dispatch`, in the existing `isinstance` chain
before the `else` that marks the union exhaustive:
```python
elif isinstance(message, protocol.Arrange):
fields = message.model_fields_set
outbox = self.registry.set_layout(
peer_id,
staged=message.staged if "staged" in fields else UNSET,
fullscreen=message.fullscreen if "fullscreen" in fields else UNSET,
)
```
Import `UNSET` from `.registry`. `model_fields_set` is the one place the
"absent ≠ null" rule survives the trip from JSON to policy; a comment there
should say so, because the next reader will try to simplify it to
`message.staged` and silently lose "back to the wall".
3. `src/mirror/app.py` — the read endpoint, next to `/api/streams`, same
`Cache-Control: no-store` (a roster that is cached is a lie about what is on
the wall right now):
```python
@app.get("/api/kiosk")
async def kiosk_layout() -> JSONResponse:
"""Server truth about the wall display: what is on stage, fullscreen, and
how many displays are obeying it. Read-only by design — the only writer is
the `arrange` frame on /ws, so there is one path that can change the wall."""
return JSONResponse(registry.layout_dto(), headers={"Cache-Control": "no-store"})
```
ASSUMPTION: writes stay on the WebSocket only. An HTTP `POST` would be a second
write path with no authentication (A11) and no same-origin protection to lose,
and the WS already gives any admitted address the same power — so the read-only
endpoint buys the `curl` debugging (AGENTS.md §8) without buying a new surface.
4. `src/mirror/app.py` — **do not** add `/kiosk` to `PAGES` yet: there is no page
until phase 08. `/api/kiosk` is gated like every other route (only `/healthz`
is exempt, AGENTS.md §3), and must not be added to that exemption.
5. No JS in this task.
## Testing & Quality
- `tests/test_app.py` — extend `TestSignalingSocket` (or the class the existing
roster tests live in) with:
* the round-trip: sharer publishes, viewer connects, viewer sends
`{"type":"arrange","staged":<streamId>,"fullscreen":true}` → both the sender
and a second viewer receive `layout` with `staged` and `fullscreen` set, and
`GET /api/kiosk` says the same;
* `followers` — two viewers with `hello{kiosk:true}` and one plain viewer
report `followers: 2`, and closing one of the kiosk sockets pushes a layout
frame that says `1`;
* a bad stream id answers `error{code:"unknown_stream"}` and **does not** close
the socket (send `ping`, get `pong`);
* an `arrange` with only `fullscreen` leaves `staged` alone end to end, and
`arrange{staged:null}` returns the wall;
* a **sharer** that sends `arrange` gets the ack, and a layout frame is not
pushed to other sharers (viewers plus sender only);
* the staged mirror's sharer disconnects → every viewer gets `staged: null`.
- `tests/test_app.py::TestAddressGate` — `/api/kiosk` is refused with the rest of
the app: add it to the parametrised "refused address gets nothing" list and
assert a refused address never learns the layout, plus one test that the real
address gets `200` and `cache-control: no-store`.
- `tests/test_app.py::TestConfigEndpoints`-style shape check: `/api/kiosk` returns
exactly the three keys (`staged`, `fullscreen`, `followers`) so a future field
addition is a decision, not a leak.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] `websocat ws://localhost:8055/ws` can drive the wall by hand: `hello`, then
`{"type":"arrange","staged":"<id from /api/streams>","fullscreen":true}` →
a `layout` frame comes back (AGENTS.md §8 rung 2)
- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` reflects that state
and answers `no-store`
- [ ] the staged mirror's sharer leaving resets `staged` to `null` for every
viewer, observed over the socket
- [ ] `uv run pytest tests/test_app.py` green (new cases + all existing, unedited)
- [ ] all four gates from `AGENTS.md` §4 green, including `-m e2e` and
`MIRROR_REALDISPLAY=1 -m realdisplay` with **no** browser-side change
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] README.md updated for anything user-visible this task changes (a protocol
note is enough here; the pages come in phases 08/09)