The four open items in TODO.md become an executable pipeline: 01 the verification gate (host suites + sanitiser + both firmware envs, and the hardware contract so no unit blocks on a board nobody is holding), 02 HTTPS and wss:// with an identity the board generates itself into NVS, 03 session takeover that always lands disarmed, 04 a responsive page whose safety statements are pinned before they are trimmed, 05 the phone as a controller (press-and-hold pad, soft keyboard, modifier latches over a new Q= frame). TODO.md is cleared to its empty title; the items live in .agents/phases/ now. AGENTS.md gains the five protocol rules and nothing else. LLM-Generated: true
5.0 KiB
5.0 KiB
Task 02 — X: hand the bridge over through the funnel
Phase: 03_session_takeover · Source: TODO.md:6 — "Need to be able to take control
if another session is connected…"
Story: n/a
Objective
One authenticated frame from the pending claim — X — takes the bridge: the incumbent is
told (E12), the release funnel runs, the board ends up disarmed, and only then is the
claimant adopted as the controller. The handover is a link change that the rest of the
firmware recognises, not a silent substitution of one socket for another.
Work
src/proto.h—Cmd::Takeoverfor a loneX, documented in the header comment's direction table:c -> d X claim this bridge for myself; requires authentication, and nothing else is honoured on a claim socket.proto::formatErrorunchanged; addkErrTakenOver = 12tocontrol_server.h("your session was taken over by another controller") and name it in the error list comment.src/control_server.cpp— the handover, in this order and no other:sendError(12, "session taken over")on the incumbent socket, so its page can say why it stopped. It is best-effort: an unreadable incumbent must not block the handover.closeLink("taken over")on the incumbent — the existing funnel.closeLink()already disarms and releases for anything that reached the WebSocket phase (that is bug 10's fix; do not re-narrow it here) and calls_onLinkChange(false), which inmain.cpprunsreleaseEverything(). This must happen before adoption, so the newcomer can never inherit a held key or an armed bridge.- Promote the claim: move its conn into the controller slot,
_phase = WebSocket,_authed = true,_controllerSeen = true,_lastInboundMs = millis(),_connectedAtMs = nowMs, reset the controller's reader, thenOK+sendStatus()and_onLinkChange(true). - Clear the claim slot completely (
_claimAuthed = false, reader reset, buffers zeroed) so a stale claim cannot be promoted twice.
arm()is not called, and nothing on this path can reach it. State that in a comment at the top of the handover function and make the function's name say it too (handOverClaim()or similar — nottakeover()which reads like an arm).- If the incumbent is already gone by the time
Xarrives, the promotion still runs through the same steps (the funnel is a no-op when disarmed), so there is only one path. _rejectedkeeps meaning "a socket was turned away", and a successful takeover gets its own counter (_takeovers), reported in the status frame (task 03) — counters only, never addresses or identities (invariant 4).
src/main.cpp—onMessage()handlesCmd::Takeoverfor the case where an existing controller sendsX(a no-op withOK, documented: you already hold it) — do not let it fall through toE6— andfillStatus()publishes the counter.src/config.h— nothing new;kClaimTimeoutMsfrom task 01 bounds the claim's life.
- ASSUMPTION:
Xis honoured only on a claim socket (or is a no-op from the controller). It is not a general command, so a controller cannot use it to reset its own session. - ASSUMPTION: the incumbent gets no warning window and no veto. Its operator is by definition absent — that is the situation the TODO describes — and a veto held by an absent tab is the lockout we are removing. The protection is that the claimant needed the passphrase, and that the board lands disarmed.
- ASSUMPTION:
E12is a new code rather than reusingE3.E3means "you were refused";E12means "you were dropped". One of those should be alarming on the incumbent's screen and the other should not.
Testing & Quality
tests/test_proto.cpp:Xparses toCmd::Takeover;Xwith trailing bytes isCmd::Bad;E12formats; the render/parse round trip covers it; the existing 2 327 checks around chord labels and state specs stay exactly as they are.tests/test_relayctl.py— the three source pins named in the phase overview: funnel before adoption, noarm()reachable from theXpath, no promotion without_authed. Written the way the existing pins are written (a named assertion per sentence, with the sentence in the output) so the suite says out loud what it checked.- Device (best effort, one attempt): arm on tab A, hold a key, take over from tab B —
watch_target.pyshows the key-up and the board reportsarmed:0; tab A showsE12. Record the transcript in.agents/HW_VERIFICATION.mdorskipped (no device).
Completion Criteria
Xfrom an authenticated claim drops the incumbent withE12and adopts the claim.- The board is disarmed immediately after every takeover, observed or recorded as unobserved-with-commands — never asserted without a mechanism.
- The three source pins exist and each one fails when its sentence is broken (shown, then reverted).
bash .agents/validate.shgreen;_takeoverscounter visible; full test suite green.