W1 (P1): every shell-authored result and event schema was .strict(), and the page fails a schema mismatch as invalid_message with retryable:false. The shell (APK) and the page (served by the desktop) ship from different releases, so one additive field or one new session tab kind from a newer APK killed the subscription and its one-shot fallback on the same byte - Loading tabs forever, surviving force-quit. tolerantMobileWebShellPayload deep-rewrites a schema at the page's two shell-payload parse sites: strict objects strip unknown keys, an array of unions drops members it cannot classify, and an unknown value for an optional/nullable closed set reads as absent. Page to shell request schemas keep .strict() - the shell is the security authority there. A census ratchet walks every contract export the page parses and fails if a strict node survives the transform.
W3 (P2): a host RPC failure collapsed into host_error, which is retryable, so method_not_found and the mobile allowlist's forbidden looked like blips. Both now map to unsupported_capability (non-retryable) through mobileWebBrokerHostRpcError, applied by codemod to the 44 regular 'if (!x.ok) throw host_error' sites.
W4 (P3): BrowserScreencastResult gains the navigation member the host already emits. Decoders unchanged.
W2 (P2): the file: confinement test gains a clientKind runtime case - pairedDeviceId is minted for scope 'runtime' too, so the fence also governs the web client, a remote desktop, and remote orca CLI.
W5 (P3): inputFloor and queryReplyAuthority stay literals with a WHY comment. Traced: the host publishes neither over the terminal stream. isMobileTerminalQueryReplyAuthority is never sent, and opcode-17 WriteUnavailable reports one refused write with no regain signal, so it is not the floor state the field declares.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The account, workspace, session, sourceControl, nativeChat, and browser ledgers were the same algorithm six times: identical record shape, admission guards, subscribe-then-reattach dance, cancel/cancelByRequest/countForOperation/dispose, and delivery chain. MobileWebSubscriptionLedger<TEvent, TRecord> now owns all of it; each concrete ledger keeps only its host subscribe call, its projection, and its operation key. MobileWebCapabilitySubscriptions replaces its four hand-written six-way fan-outs with loops over a ledger list.
postClosed stays a required constructor option and every construction site, tests included, now supplies it - three test fixtures previously left it undefined, which is why no shell-side closure frame had coverage.
Adds the missing closure-path tests: every ledger's invalid-host-message and failed-page-post paths, plus dispose/closeAll behaviour. dispose() stays silent toward the page because the document is going away with the shell; the new closeAll(closure) is what the broker calls on replaceClient, so a page that outlives a client swap learns its live subscriptions are over instead of freezing on their last value.
Behaviour change: a duplicate nativeChat subscription ID now raises invalid_request like the other five ledgers instead of rate_limited. The broker's replay guard rejects duplicate subscription IDs before the ledger sees them, so the path is unreachable in production.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Replacing the policy call with `true` shipped a release APK with WebView
DevTools on and passed every gate: the only assertion tying the probe to the
policy pinned the absence of an old code shape, and the JVM suite always passed
isDebugBuild/isInspectableRelease explicitly, so the BuildConfig defaults the
sole caller uses were never compiled, let alone run.
The census asserts the policy-gated call is the one and only
setWebContentsDebuggingEnabled in the shell. The JVM test drives the
one-argument overload, and testDebugUnitTest now finalizes testReleaseUnitTest
so the shipped BuildConfig values are exercised under the CI command.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A relay/direct cutover rejects in-flight requests without changing `connState`.
The capability probe retries; the package downloader did not. Every throw
collapsed to `host_error`, and the refresh effect's deps are all unchanged by a
seamless cutover, so nothing re-ran and the user had to tap Retry — on the one
screen that has no content yet.
The contract now marks a cutover or ambiguous-delivery throw `retryable`, using
the same two predicates the native-chat send path already trusts, and both
package reads re-issue it on the replacement session with bounded backoff: the
chunk read next to its existing read-limited retry, and the manifest read, which
is the one request a cutover can kill before any chunk exists to retry.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Neutering throwIfAborted survived: the one abort test was killed by the chunk
pipeline's own check, not by the downloader. Each case here aborts where only
the downloader's check can catch it, paired with an un-aborted control that
reaches stager.commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The bridge had seven shell -> page frames and none of them closed a
subscription. `response` is keyed by `requestId` and only reports the subscribe
call; `event` carries values. Once the subscribe response said `success`, every
shell-side failure after that point was unrepresentable — and the shell failed
subscriptions late in six ledgers, calling `cancel()` and discarding the request
id the signature returns for exactly this purpose. The page kept a live entry
with no timeout and no heartbeat, so tabs, workspaces, source control, accounts,
native chat, and dictation could all freeze on their last value with no error.
Adds `subscriptionClosed { subscriptionId, error: { code, retryable } }`, which
is additive to bridge version 2: a page built before it fails the union parse,
and `native-shell-channel` already returns on a parse failure rather than
tearing the session down, so an older page ignores it exactly as it ignores any
unknown type.
Every ledger's `cancel()` now takes an optional reason and posts the frame, and
the page routes it to the existing `fail()`, which already deletes the entry and
calls `onError`. Normal end-of-stream retirements pass no reason, so they stay
silent. No UI or presentation change: this is protocol completeness, and the
retry policy on top of `retryable` is a separate decision.
Two files crossed max-lines from the additions, so the event-verdict logic and
the closed-frame poster moved to modules of their own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The binding test banned Clipboard.setStringAsync, Linking.openURL and
router.push('/terminal-settings') over a 42-file list, so injecting all three
into MobileTerminalInputActions.tsx passed. The census walks the 1052 modules
the hosted session route actually reaches and catches it.
AsyncStorage is deliberately excluded: it is genuinely reachable from the hosted
bundle today through the shared storage and transport modules.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Seeding the walk with an empty pending list made the reachability oracle pass
over an empty universe. The walk moves to a shared support module so the
session-route census can reuse it instead of re-implementing the resolution order.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS appeared in no test, so disabling the
shared 64-request cap survived, as did dropping the maxResponseBytes check.
The saturation fill uses distinct operations so the shared cap is what fires.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Two limits govern the same snapshot and they disagreed. The 200-tab count limit
slices, keeps the active tab, and reports `truncated`. The 128 KiB event byte
cap cancelled the subscription with no frame the page could see, and since a
browser tab at the schema maximum serializes to roughly 5 KB, ~40 long-URL tabs
crossed the byte cap long before the count cap ever fired. The page then showed
"Loading tabs" forever, deterministically, on every re-entry into that
workspace.
The byte cap now drops tabs to fit — active tab reserved first — and reports the
same `truncated` flag, so the projection is the single place that decides how a
snapshot degrades.
The sibling native-chat cap is left alone: it bounds one incremental chat event,
which has no partial form to degrade to.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
`session.createBrowser` is reachable from unprivileged page script and the page
it creates is streamed back over `browser.screencast`, so a `file:` URL turned
any host file into frames the page could decode. `browser.navigate` already
refused `file:` and result URLs were redacted; only the create path was open.
Both sides of the call now fence it instead of banning the scheme, because the
native HTML-artifact file tap is the same call:
- the shell re-resolves the path through `files.resolveTerminalPath` against the
workspace the page named and forwards the host's own absolute path, so nothing
the page wrote reaches `browser.tabCreate`, and an SSH worktree (whose path is
on another machine) is refused;
- the runtime independently requires a paired caller's `file:` URL to sit inside
that worktree's root on this host, so the page is not the only fence.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
mobile-web-history-session-fragment and mobile-web-queryless-history were the
same module twice: same writer/target types, same private WeakSet, same origin
guard and try/catch, differing only in the URL mutation. Both installed at
module scope on the same history object, so every pushState was double-wrapped
and each navigation parsed the URL twice.
One installer now takes an ordered rewrite list behind a single WeakSet and a
single URL parse. The shell-session fragment gate moved from install time into
the rewrite, which is where the hash is read anyway.
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
The account, session, source-control, workspace, and browser ledgers each
declared a byte-identical private error class, but mobileWebBridgeErrorCode
recognised only the session one. The other four collapsed to host_error, which
also flipped retryability: a malformed subscribe or a hit per-ledger cap read
as a retryable host fault.
All five now throw the shared MobileWebBrokerError, so the converter has two
branches and no longer imports a ledger.
The downgrade is latent rather than live: the four non-session grants pin
maxConcurrent to 1 and the replay guard pre-empts duplicate subscription IDs,
so the broker's own guards fire first today. The new test covers both layers —
the per-ledger converter path (red before this change for all four) and an
end-to-end proof that a duplicate surviving replay-ring eviction reaches the
page with its own non-retryable code.
Leaves the shared ledger shell unextracted: cancel is not mechanical across the
six (browser clears a pending frame, source-control latches a closing flag), so
a base class would need per-ledger hooks.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Opcode 18 set inputKind:'query-reply' on the client's word alone, and that
kind forces clientId undefined in sendTerminalStreamInput, so the frame
skipped beginMobileInputFloor entirely. A phone that is not the elected
reply authority could write arbitrary bytes into a desktop-driven pane
unfloored.
Lift the terminal.send guard into terminal-query-reply-guard.ts and apply it
at all three sites. A binary frame that fails is dropped, matching the JSON
path, which throws on shape and returns accepted:false on authority — neither
demotes the bytes to ordinary input. The page-side sender now runs the same
isTerminalQueryReply grammar check the native sender does, so the phone never
emits a non-grammar query reply on either opcode.
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
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
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
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 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
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
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
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