feat(box): let the phone app read conversations, follow events and get notified
Second of the phone app's PRs (after #206; independent of it). The box side of the iPhone app: version 2 of the phone API on the tailnet listener (:1379), behind the existing token, Host check and lockout.
New phone routes (internal/box/phonev2.go, documented in docs/reference/phone-api.mdx)
GET /phone/v1/infonow saysversion: 2andcapabilities.GET ā¦/sessions/{name}/transcript?since=&gen=: the recent conversation, copied field by field into the phone's own structs, so a field added totranscript.Itemnever reaches a phone by accident. No tool ids, uuids, signals, artifacts, crew details or record path;!output cut to 2 KB; no?before=or tool details.GET /phone/v1/events?since=: NDJSON like/v1/events, but an allowlist that rebuilds each event: agent/session/run state andtranscript.changedby name, never paths, logins, secrets or other events. Replays at most 500; at most 16 streams per box; a new code or access turned off ends them (the listener closes).POST /phone/v1/push,DELETE /phone/v1/push/{id}: a phone app registers the key the push relay gave it. A phone can never set the relay (a laptop can,PUT /v1/phone {relay}, https only). New gatebefore:phone.push, eventsphone.push.added/removed. At most 10 phones.POST ā¦/sessions/{name}/interrupt: Stop through the driver, gated assession.sendwithfrom: "phone"(refactored out ofinterruptSession).
Notifications: notify() is split into a notice plus ntfy and relay deliveries, run side by side. The relay gets the same fields ntfy does (title, "loc/wt on box", session or run), never agent text. 410 forgets the phone, 5xx retries once, the result is noted per phone. Outbound goes through outboundPolicy() (public addresses only). GET /v1/phone lists phones without their keys; rotating the code or turning access off forgets them all. Capability phone.push for laptops.
Tests (phonev2_test.go): the projection leaks nothing; the stream carries only allowed types and fields; replay cap; stream cap; rotate ends streams; register, forget and rotate-clears; a phone can't pick the relay; registration gate; relay and ntfy both notified, no content; 410 forgets; 5xx retried and noted; loopback refused; interrupt gated. While writing these, -race caught a cursor closed while its reader was in Next; the phone stream closes it in the reader goroutine. streamEvents in api.go has the same pattern and is untouched here.
Docs: phone guide (what's new, the relay, threat model), phone API reference, events, security, changelog.
Checks: go vet ./..., go test -race ./..., docs-site build.
š¤ Generated with Claude Code