Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.
The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.
Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.
src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.
# Conflicts:
# android/.gitignore
# android/README.md
# android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
# android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
# android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
# android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
# android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
# docs/PROGRESS_LOG.md
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
Both had shipped on the server for a while and Android silently discarded
them, which is worse than not supporting them — the UI looked correct while
missing information the user needed.
W2 `queue`: `ServerMessageType` had no member for it, so `decodeServer`
returned null and `SessionEngine.onFrame` counted it as undecodable and
dropped it. The "N queued" badge could therefore never appear on Android,
on any device, ever. Adding the branch made the compiler flag the
non-exhaustive `when` in the engine, which is the same gap seen from the
other side. A negative depth drops the frame rather than feeding nonsense
to a badge; the next broadcast carries the real value.
W1 `status.preview`: not modelled at all, so a remote approval was BLIND —
the gate could show that `Bash` wanted to run, but not what it wanted to
run, and the same for an Edit's diff. Approving blind defeats the point of
the remote gate, which exists so whoever is holding the phone can make an
informed call. `ApprovalPreview` now models both variants and
`GateState.preview` carries it to the UI.
Two decode rules are load-bearing and pinned by tests:
- A broken preview must never cost the FRAME. `pending` is what makes the
gate appear at all, so trading a missing preview for a missing gate
would be a bad bargain. Every malformed shape — null, non-object, no
kind, unknown kind, command without text, diff without a file — degrades
the preview to null and still yields the Status.
- Unknown values are kept, not coerced. A diff line kind or file status
this client has never seen renders as itself; dropping a line we cannot
classify would hide part of what is being approved. Only a diff file
missing its paths is dropped, because then the preview cannot be trusted
to describe the right file.
A sustained pending refreshes the preview without minting a new epoch: the
server's bounded render can arrive a frame after the gate rises, and a
decision tapped in between has to stay valid.
The preview's diff subset lives in :wire-protocol rather than reusing the
:app diff model — :session-core may not depend on :app, and new wire types
belong in the frozen contract module (CLAUDE.md).
Verified: :wire-protocol:test :session-core:test + both koverVerify green;
16 new tests (10 codec, 6 engine-level).