PLAN.md locks the architectural decisions (WebRTC mesh with the sharer as sole offerer, JSON-only signaling, in-memory roster with no database, no auth, no CDN, Linux as the reference platform) and records the roster rules, the verification ladder, and the constraints that are accepted rather than hidden: the secure-context requirement, mesh upload scaling with watcher count, and the Wayland portal's per-share prompt. Phases are deliberately not written yet -- the prototype and its tests came first, so .agents/phases/todo/ holds only a README pointing at the candidate phase list in PLAN.md section 7. SECURITY.md answers the question a no-auth tool has to answer: not "who is allowed in" but "where is this allowed to run". It separates what an unauthenticated LAN visitor can do from the guarantees that are enforced and named to a test, and tells users the two things they will actually hit -- that http://<lan-ip> hides getDisplayMedia, and that on Wayland the compositor asks every time and mirror must not work around it. LLM-Generated: true
7.5 KiB
mirror — Agent Operating Rules
Read .agents/PLAN.md before touching anything here. It holds the locked
architectural decisions; this file holds the rules for working inside them.
mirror is browser-only screen mirroring for a local network: /mirror shares a
window/screen/tab via the OS-native picker, /watch shows every live mirror and
lets anyone take one fullscreen. Media flows peer-to-peer over WebRTC; the server
is a roster and a signaling relay. Linux is the reference platform.
1. The order of reading
.agents/PLAN.md— architecture, locked anchors, verification ladder.README.md— the user-facing contract.SECURITY.md— why there is no authentication, and what that costs.src/mirror/registry.py— the whole policy lives here. Read it before changing behavior; it is short and it is the product.
2. Phased execution
.agents/
├── PLAN.md ← locked design (do not edit without an explicit decision)
└── phases/
├── todo/ ← pending phases: NN_name/00_phase.md + NN_task.md
└── complete/ ← finished phases, read-only history
- New work enters through the
phase-authoringskill, never as a direct implementation of a vague request. - Phases run through the
phased-executionskill. - Never edit anything in
phases/complete/. Ask before editingphases/todo/. - Never edit
PLAN.mdunless the user explicitly authorises a change to a locked decision, and record the reason in the same change. - A phase is not done until every gate in §4 passes.
(The roadmap is intentionally empty at the prototype stage: a working prototype
came first, and the candidate phase list is §7 of PLAN.md.)
3. Locked decisions that are easy to break by accident
| Rule | Why it exists |
|---|---|
No external assets. No CDN, remote font, remote icon, @import, or analytics |
LAN/offline correctness. tests/test_app.py::TestClientScripts::test_nothing_is_loaded_from_a_cdn fails the build if you add one |
No framework, no bundler in src/mirror/static/ |
Two pages and one WebSocket. node --check is the entire frontend toolchain |
| Media never passes through the server | The server relays JSON only. Anything that puts frames in the Python process violates A3 |
Registry stays pure — no sockets, no FastAPI, no await |
It returns list[Envelope]. Transport lives in Hub. This is why the policy has 100%-ish unit coverage |
camelCase on the wire, snake_case in Python, via alias_generator=to_camel |
A silently-dropped displaySurface was a real bug we shipped. Any new multi-word field needs an alias test |
| No authentication (A11) | Product brief. If you think it needs auth, that is a PLAN.md discussion first — and see SECURITY.md |
| No STUN/TURN by default | A LAN tool must not announce internal addresses to a third party |
| Teardown is broadcast to every viewer, not just subscribers | Tiles are drawn on stream_added; a non-subscribed viewer still has a stale tile to clear |
Keep both <video> elements muted playsinline |
Autoplay policy on iOS/Safari otherwise blocks the wall entirely |
4. Gates — run all four before calling anything done
uv run ruff check . && uv run ruff format --check .
uv run pyright # must be 0 errors
uv run pytest # unit + API + browser E2E (~20s)
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture
Coverage is ~94%; the media path is covered by browser assertions, not by unit tests pretending to be WebRTC.
5. Browser testing rules
Non-negotiable, all learned the hard way (full notes in PLAN.md §5):
- Launch with
channel="chromium". The headless shell has no media stack —navigator.mediaDevicesdoes not exist there. - Serve pages over
http://127.0.0.1:<port>. Onabout:blankor a plain LAN IP,mediaDevicesis undefined and the test proves nothing. - Deterministic capture:
--use-fake-ui-for-media-stream --use-fake-device-for-media-stream→ a real, moving 1280×720 monitor stream. - Real capture (the
realdisplaysuite): private Xvfb, headed Chromium,--auto-select-desktop-capture-source="Entire screen",--ozone-platform=x11, and never--use-fake-ui-for-media-stream(it approves the request without selecting a source and the capturer dies withNotReadableError). - Strip
WAYLAND_DISPLAYandDBUS_SESSION_BUS_ADDRESSfrom any headed browser env (tests/e2e/test_x11_capture.py::browser_env). Otherwise the portal captures the developer's actual desktop and pops a permission dialog on their real screen. - Assert media on the viewer's
<video>— decoded size and frames that differ. The sharer's preview proves nothing crossed the wire. - Two browser processes, not two contexts, when a test means "two machines".
6. JavaScript rules
- Plain ES modules loaded with
<script type="module">. No globals beyondwindow.__probein test scaffolding. - Every element a script looks up (
$("#id")) must exist in its page — enforced bytest_every_element_the_script_reaches_for_exists. - Element ids must not collide across pages (one shared stylesheet):
test_the_two_pages_share_no_element_ids_but_the_shared_chrome. The only allowed shared ids arenet,notice,toast. - WebRTC: the sharer always offers, the viewer always answers. Queue ICE
candidates until
remoteDescriptionexists; serialise offers per peer to avoid glare. Reusenet.jsrather than duplicating socket logic. - Never move a
<video>element'ssrcObjectto "switch views" — move the element (that is how/watchstaging works) so the peer connection survives.
7. Python rules
from __future__ import annotations, full type hints, Google-style docstrings on public functions.- Starlette ≥1.10 raises
WebSocketDisconnected(aRuntimeError) when the app has already closed a socket, andWebSocketDisconnectwhen the peer does. Catch both; read a socket you closed and you will crash the connection handler. - Registry errors are
RegistryError(code, message, fatal);fatalmeans the socket closes after the error frame is delivered. New error codes get a test. - No bare
except:; swallow only what you can name, and say why in a comment.
8. What to do when something behaves mysteriously
Reproduce at the lowest layer that can fail:
curl -s localhost:8055/api/streams | python -m json.tool— server truth.websocat ws://localhost:8055/ws— speak the protocol by hand (see PLAN §3).uv run pytest tests/test_registry.py— is the policy wrong, or the wiring?- Only then reach for a browser, and print
pageerror/consolefrom the first line of the test.
9. Commits
- Conventional Commits (
feat:,fix:,test:,docs:,chore:). - Always
--no-gpg-sign, always append the trailerLLM-Generated: true(thegit-commitskill does this). - Never commit
.env, keys, or.agents/phase-sessions/.
10. Quick reference
| I want to… | Do |
|---|---|
| Run it | uv run mirror serve → :8055 |
| Share / watch | /mirror · /watch |
| Debug with an editor | DEBUGPY=1 uv run mirror serve (port 5678) |
| Server truth about mirrors | GET /api/streams |
| What the browser may know | GET /api/config |
| Everything fast, no browser | uv run pytest -m "not e2e" |
| Only the real-desktop test | MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay |