Three native-path regressions plus a doc correction:
- Unpair awaited removeMobileWebHostCache before removeHost. The native store
throws on an empty identity or a failed tree delete, and that cache need not
exist at all on a native build, so a hybrid-only failure stranded a paired
host. Both cache cleanups are best-effort now.
- Activation diagnostics dropped the target and the RPC failure code, leaving
concurrent activations indistinguishable and failures unexplained. Restore
the redacting helpers from main; a new test pins that only the 8-char suffix
reaches the log.
- The Sleep action lost its `.catch`, so a rejected fire-and-forget sleep
surfaced as an unhandled rejection.
- The README claimed an unset architecture keeps native. It does for release
builds, but a development build defaults to hybrid; document the real rule
and how to opt a dev build back into native.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The stream presentation replaced runTerminalViewportFitPass with its own
correction pass, leaving the STA-3337 budget built and cleared but never
charged. It also coerced absent host dims to 80x24, which can never equal a
phone viewport, so a host that omits cols looped scrollback -> measure ->
unsubscribe -> resubscribe with no cap, backoff, or degrade toast.
Restore readTerminalViewportDims plus the `hostCols ?? viewport ?? 80`
fallback (absent dims now yield hold), call runTerminalViewportFitPass from
the presentation, and let a `resized` frame charge convergence through
observeResize. Two other main behaviors return with it: a stream end/error
signals terminal-inventory recovery so `exit` retires the tab promptly
instead of waiting for the 60s sweep, and the display-mode Map keeps its
identity when the mode is unchanged so a stream pass stops re-rendering the
whole route.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Home Resume/Tasks/Accounts and notification taps went back to a plain
router.push into the nested host navigator. On the native build a cold push
there resolves to the host index without the dynamic id, so HostProtocolGate
mounts with hostId undefined and the host screen renders blank (the bug #12001
fixed by mounting /h/[hostId] first and replacing once its stack commits).
navigateFromMobileHome now maps a MobileWebNavigationIntentTarget onto the
matching HostStackRouteTarget and hands the deep ones (session, tasks,
accounts) to coordinateHostStackNavigation via useOpenHostStackRoute. Host-index
intents (newWorkspace, workspaceList) and the whole hybrid build keep their
plain push, and the hybrid navigation intent is still published either way.
The Resume card also regains the `name` param it lost, so the session header
has a title before the workspace loads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Resolves#16239's shared-client terminal identity against the hybrid split: the
hosted page has no native client, so identity readiness is a flag
(hostClientIdentityReady) rather than a non-null clientId, and the bridge
terminal operations keep their workspaceId/terminalId/clientId contract.
Adds getClientId to the disabled hosted client context and keeps the
mobile-web extra resource alongside main's new emoji shortcode dataset.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A rejected session snapshot (invalid shape, oversize, or a failed post) cancelled
the shell-side subscription silently, the bridge client discarded the late error
because the subscribe request had already resolved, and the page kept "Loading
tabs" with no error and no retry. The shell now posts an error on the subscribe
request id, the client routes it to the subscription's onError (which already
falls back to polling), and the session screen shows Retry after two consecutive
failures. Reuses the existing response opcode, so mixed versions degrade to
today's behaviour.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
`-PorcaInspectableRelease=true` marks the release variant debuggable through an
Expo config plugin and flips a build config field the shell's inspection policy
reads. The OS debuggable flag stays a hard requirement, so a shipped production
APK can never be inspected regardless of the Gradle property. Default builds are
byte-for-byte unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Tabs opened from outside the worktree carry an absolute path; the hosted
snapshot stripped it and the read payload was rejected before any request.
The shell now resolves the file from its own session.tabs.list by tab id,
carries isDirty through, clamps oversized reads to read-only instead of
failing, and the retry state names the error code.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Placeholder sections for repos with no rows were built before the paged
worktree list landed, so every repo flashed as a count-0 header. Gate them
on rows loaded, and let the hosted host state reuse the in-memory cache so
returning from a session keeps the last complete list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A desktop update swaps the hosted page under the user; the shell reset the
remembered route to the workspace list on every session id, so a session
only came back through cold resume after the list mounted and fetched.
Scope the memory to the host so init replays the session route directly.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The hosted page pushed /connection-log page-locally; the page has no such
route and cannot show the shell's transport log anyway. Hand the shell a
connectionLog native route instead, and fence the class with a reachability
test over the hosted module graph. Re-pair goes through repairPairing too.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Reconciles main's structured native Codex chat (#18074) and the stage-aware
relay dial bound (#18518) with the hybrid operations layer:
- native-chat controller keeps the operations/target/disconnect-retention
seam and routes agent-session tabs through the structured hooks; the
active-resolution and terminal-write hooks are extracted to stay under
the line cap
- image attachments keep the hosted attachImage/pasteImages path and add
main's structured (paste-free) send
- bare Codex launches take the structured path only on the native client;
hosted adapter creates stay on the adapter
- file taps resolve against the source session tab when a structured chat
has no backing terminal
- relay session advertises client capabilities after resume confirm
- package downloader contract split out to break the import cycle the
native code-quality audit now rejects
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): finalize structured native Codex chat
* fix(mobile): close structured chat lifecycle gaps
* wip(mobile): fence stale structured inventory and bound operation-id retention
Fence local structured-session inventory and subscription responses with a
sync generation so a toggle-off clear, reconnect restore, or retry cannot
apply a mirror from a superseded instance. Bound mobile ambiguous
operation-ID retention at 128 with unmount cleanup.
Staged on the reconcile branch only: the sync module is now 312 lines and
needs a real split before this can reach the PR head.
* fix(ci): split the structured session-tabs sync and give static analysis mobile types
The local structured session-tabs sync module outgrew the 300-line cap once it
took on generation fencing, so split it along its real seams instead of raising
the cap: the generation/cursor fence, snapshot projection, snapshot apply,
inventory refresh, and the subscription loop. The original path stays as a
barrel so no importer moves.
Repoint the host-session-mirror settle census at the apply module, which owns
two receipts now — the snapshot it mirrors in, and the toggle-off teardown that
retracts what it published. The teardown receipt is named rather than anonymous
so the pin says which direction it settles.
The changed-code quality gate lints mobile files and resolves their types from
mobile/node_modules, but mobile is a separate pnpm project that the root install
never populates, so every mobile type degraded to an `error` type and the gate
reported phantom findings. Install mobile dependencies in static analysis when
the diff touches mobile, gated on a new classifier output.
* fix(mobile): let a slow capability handshake still reach connected
The mobile capability update is an advisory whose result is discarded, yet an
unanswered one was fatal while an explicit rejection was tolerated. A 5s timeout
on the direct client force-closed the socket, and on the relay path it failed
`confirmResume` before `connected` was ever published, so a consistently slow
link redialled forever. Both paths now share one helper that settles every
ambiguous outcome (timeout, mid-flight drop) like a rejection and rejects only
when the frame never reached the wire — the one case nothing else recovers from,
since the socket's own desync force-close is gated on already being connected.
The generation guard still keeps a replaced session from connecting.
Retained structured-session operation ids were capped at 128 with oldest-first
eviction, but every retained id belongs to a send whose outcome is unknown, so
eviction turned a user's retry into a second message on the host. Bound the map
by expiry against the id's own embedded timestamp instead, mirroring the host's
operation ledger, so no id is released while the host would still honour it.
Also give the mobile CI install the root install's lockfile drift guard (mobile's
lockfile carries patchedDependencies a silent rewrite would drop), gate
mobile_dependencies on should_run, and key the pnpm store cache on both lockfiles.
* refactor(mobile): extract the relay pending-request registry
The merge composed two independently-sized changes — this branch's capability
handshake settle and main's dial-stage tracking — pushing the relay session file
to 304 lines against a 300 cap. Neither side broke it alone.
Move the in-flight request registry (id generation, tracking, settlement, and
reject-all with its delivery-ambiguity marking) into RelayPendingRequests,
matching the existing collaborator pattern alongside RelayDialStageTracker and
RpcSessionLivenessWatchdog. No behavior change.
---------
Co-authored-by: Merge Sim <sim@local>
A phone returning to foreground on 2026-09-03 logged "replacement session
authentication timed out" five dials in a row while the desktop's relay
control was live. The cell (production-gce-c27) had taken relay-auth but
its assignment/reservation transactions were lock-contended (55P03 retries,
14–16s per accept); the phone's flat 12s migrateTo bound closed the socket
2–4s before the cell finished (cell logged host_data_reservation_already_bound),
and because the timeout counted as a director-class failure the phone
re-resolved the same cell and waited 12s again before logging — every
retry landed in the same contended window.
- MobileRelayE2eeLink reports onOpen once relay-auth is on the wire;
MobileRelayRpcSession exposes a dial stage
(opening → awaiting-hello → handshaking → confirming).
- waitForAuthenticated keeps the caller's bound until the socket opens, then
re-arms a per-stage budget (30s awaiting-hello, 12s handshaking, 35s
confirming) so a reachable, slow cell is not treated as a black hole.
- The timeout error carries the stalled stage and shows up in the
"relay dial failed" log line; a stall past the open socket no longer
triggers the director re-resolve round.
Phone-local only: no wire change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Over a live hosted page the progress block sat at the left edge with no surface
of its own. Give it the panel background, centered layout, and a hairline
divider so it reads as a banner above the page.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Frame layers were swapped through `setNativeProps`, which react-native-web refs
(DOM nodes) do not have. Opening a browser tab in the hybrid page threw inside
the ref callbacks, the route error boundary caught it and reset to `/`, so the
tab flashed and bounced back to the workspace list. Mutate the DOM directly on
web and keep `setNativeProps` on native.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Shared session code exits to `/h/<hostId>` when there is no history to pop
(leaveSession after a fresh page load into a session, the missing-worktree
bounce). The hosted page only listed workspaces at `/`, so those exits
rendered expo-router's Unmatched Route screen. Alias the host index to the
hosted list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
useHybridHostRepoMetadata listed the whole state object as a dependency, so the
callback and the refresh effect that depends on it re-ran on every render. Each
refresh fetched, set state, rendered, and re-armed: a self-sustaining request
loop the shell broker rate-limited, which the list showed as network_error.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The hosted page reads `connected` from the shell's relayed snapshot, so its
first worktree.ps can be issued before that socket serves one. Page storage is
disabled, so the hosted list always starts at zero rows — the precondition the
list state machine needs to render `catalog-error` — while the native list is
seeded from its persisted cache and hides the same failure behind stale rows.
Let a relayed transport spend one silent catalog retry per binding: the first
failure leaves catalogError null, so the list stays in `loading` and the
existing refresh retries. A direct socket omits the flag and is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The Expo shell pads the container that holds the WebView by the device top
inset, and the hosted page pads again from env(safe-area-inset-top). Android
derives that value from the window's display cutout without subtracting the
WebView's offset, so every hybrid screen sat a second status bar below the
system one. iOS never showed it because WKWebView recomputes its own safe area
from the view's position.
The shell keeps the inset. The hosted route root now pins the page's top inset
to zero and leaves the edges the WebView still meets alone, so the reserved
space is applied exactly once on both platforms.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Animated.loop branches on the raw `useNativeDriver` flag, not on driver
availability. On react-native-web that sends the loop down `_startNativeLoop`,
which starts one JS-driven timing pass whose `iterations` the JS driver ignores,
so the spinner ramps 0 -> 1 once and freezes. Verified against the real
react-native-web Animated: 3.5s of frames yields zero restarts with the flag on,
3+ with it off.
The same components also lost their ring gap on web: an inline `borderColor`
after a StyleSheet `borderTopColor` is emitted as four inline longhands that
outrank the class, rendering a solid ring instead of an arc.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The hybrid screen's focus effect calls activateSessionView, which on Android
routes to activateViewSession and required the view to already be in the
module's session registry. The registry is filled by the sessionId prop commit,
so a focus that lands first threw mobile_web_shell_view_unavailable and the
screen pinned "Hosted session could not be restored." above a page that was
healthy the whole time. Nothing clears that warning, so the banner also kept the
WebView pushed down for the rest of the session.
The prop is the activation authority, so a view missing from the registry is
mid-commit for the same session, not a failure.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Deriving the host label by slicing the session id forced the id itself into a
hostname alphabet, and the previous fix changed the native stores to mint
lowercase base32. But the session id is a wire token: `ShellSessionIdSchema` in
`src/shared/mobile-web/bridge-contract.ts` pins it to 43 base64url characters,
and the page that validates it is served by the desktop, which updates
independently of the app. A 52-character base32 id therefore failed
`parseMobileWebBridgeInitialMessage` inside the page, which silently dropped
`init`: no bridge client, no `ready`, no `health`, and a workspace list that
spun forever behind "The workspace interface has not reported healthy".
Session ids go back to base64url and the origin label is now the first 32 hex
characters of their SHA-256. Lowercase hex is canonical for Chromium's host
canonicalisation and parseable by `java.net.URI.getHost()`, so the original 403
and dropped-bridge-message defects stay fixed without touching the wire token.
`mobile-web-shell-init-contract.test.ts` parses the exact init the hybrid screen
posts, which is the oracle that was missing: nothing checked that the shell's
own init survives the contract the page enforces.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The hybrid app pulled its 9.1 MiB mobile-web package one 48 KiB chunk per RPC
round trip, strictly serially, and 99.3% of the package is a single 9.5 MB
script — so the whole download was 245 sequential round trips against whatever
the relay path's latency happened to be. Measured on the real package over the
real mobile relay client with a 120 ms round trip: 31.7 s (0.30 MB/s).
Two changes, both negotiated:
- the downloader now keeps up to MOBILE_WEB_PACKAGE_MAX_CONCURRENT_READS reads
in flight across the whole manifest while still draining to the stager in
offset order (the native stage appends at the file's current length), and
narrows the window instead of failing when a host answers
mobile_web_package_read_limited;
- mobileWeb.package.asset.gzip takes an optional `length` so one read answers up
to eight chunks. The params schema is strict, so older hosts reject the field —
it is gated on the new mobileWeb.package.range.v1 capability, and the client
still splits the range into 48 KiB stage writes.
Same package, same harness, relay path: 31.7 s -> 2.8 s at 120 ms RTT and
62.9 s -> 5.5 s at 250 ms RTT, with 245 requests down to 76.
The download still dies when the app is backgrounded past
RELAY_BACKGROUND_GRACE_MS — the session suspends, the refresh effect aborts, and
the stage is discarded — so the progress panel now says to keep the app open.
Resuming from the staged offset needs a native stage-reopen API on both
platforms and is not attempted here.
mobile/scripts/measure-mobile-web-package-download.mjs drives the real
downloader over both the direct and relay mobile clients with an injectable
round trip, which is where every number above comes from.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A missed interactive health deadline recovers to the previous verified
generation. With no previous generation the recovery throws and the shell bumped
the view epoch, which remounts the WebView, reloads the document and re-arms the
very deadline that expired. A page that simply needs longer than one deadline
could therefore never finish loading: on the Android emulator the hosted
document reloaded every ten seconds indefinitely.
The restart is now only taken where the view is actually gone (crash loop) or
the user asked for it (manual recovery). A health timeout with nothing to
recover to keeps the page and reports the warning.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
`reuseVerifiedBuild` only reused a build the cache probe had verified. That
probe resolves null for a host paired for the first time, so every later
refresh in the same host epoch re-downloaded the whole package — minutes over
the relay — even though the running session was already on that build. An
owned session only ever comes from `openSession`, so its build is verified by
construction; reuse it directly.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The Android private origin's host label is the session id's prefix, but the
ids were base64url. `https` is a special scheme, so Chromium ASCII-lowercases
the host of every URL it loads and reports back, while `Uri.parse` keeps the
mixed case the shell derived: `serveRequest`'s origin check rejected the main
document and answered 403, which Android renders as
`net::ERR_HTTP_RESPONSE_CODE_FAILURE`. `java.net.URI.getHost()` is also null
for a label holding `_`, so the same ids dropped every bridge message.
Session ids now come from a lowercase base32 alphabet,
`mobileWebOriginForSession` rejects anything a URL host cannot carry, and host
comparisons are case-insensitive. A failed main-frame document no longer stops
at Chromium's error page: the shell hides the WebView and reports `failed` with
a reason the React Native shell shows.
iOS uses a custom scheme, whose opaque host preserves case and `_`, which is
why only the Android lane saw this.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The hybrid shell spoke in implementation terms — "Verified desktop-served
interface", "Preparing verified interface…", "Connect to this desktop once to
cache its verified workspace UI." — and rendered four equal recovery buttons
inside a grey box.
Every user-facing string is now one plain sentence, carried as a
MobileWebShellNotice so the stable failure code lives on a small "Error: <code>"
support line instead of inside the copy. The header drops its subtitle, the
download state collapses to one line plus the bar, and recovery promotes Retry
to the app's primary button style with the rest demoted to text links; the
grey container is gone. Recovery labels move to Use last version / Reset and
carry stable testIDs so e2e no longer depends on visible copy.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Why: main #18xxx added repoHostIdByRepoId/hostLabelById/hostPlatform to the native host
screen state that HostScreenView consumes; the hybrid twin hooks must expose the same shape
or the hosted route fails typecheck. The hosted bridge publishes no host labels yet, so the
maps stay empty and rows keep today's single-host presentation.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A relay teardown calls agentHookServer.clearStatusEntriesForConnection, so an
unreachable SSH host reports a terminal with no agentStatus. Both native-chat
binding resolvers read that as "no such session" and returned not_found, which
makes loss of contact evidence the session is gone — the failure mode
docs/reference/ssh-execution-boundary.md exists to prevent.
Whether the spec saw it was pure timing: the mobile-session snapshot only loses
the provider session once a refresh lands inside the disconnect window. A 3s
wait after disconnect turns it into a deterministic failure on the previous
commit, which is what CI was hitting.
Runtime side: remember the last-known agent and provider session per terminal
handle, and fall back to it when the live tab no longer carries one. The
terminal context stays the existence gate, so a closed terminal still resolves
to nothing and drops the memory with it.
Broker side: only a vanished tab, a different terminal, or a tab rebound to
another provider session revokes the opaque grant. A tab whose host simply
stopped reporting status keeps it, so the read surfaces host_error.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The failure printed only landmarkDelta, which cannot say which side moved. The
two landmarks come from different sources, a native accessibility frame and a
hosted DOM rect, so the absolute pair is what makes the next failure readable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The landmark delta is bit-identical across the run before the change and the
run after it (0.047498512585812364 both times), so re-reading after the settle
changes nothing and the animation rationale was wrong. Reverting rather than
leaving an inert change carrying a disproved explanation.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Opening a changed file from a session-origin Source Control sends
files.openDiff, which needs a renderer notifier. The e2e pairs against
`orca serve --mobile-pairing`, which has none, so the host answers
renderer_unavailable on native and hybrid alike. The journey asserted the diff
tab unconditionally and could never pass.
The native baseline now probes the same session-origin path and records what
the paired host answered. The hosted journey takes whichever arrives first, the
diff route or the bridge error, and pins it against the native result, so the
claim is "hybrid matches native on this host" rather than "hybrid fails". On a
full desktop the diff route wins and reviewOpen.headless records false.
Kept as evidence, not fixed: the hybrid banner reads "Source control action
failed" where native names renderer_unavailable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A single accessibility read caught the Tasks list mid-load on one client and a
second, transient xterm helper node on the other, and both read as client
differences. Poll until two consecutive volatile-free reads match, then
screenshot. Re-running the A/B with this in place left the twelve stops
identical except for live GitHub issue content.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb