Commit Graph
11496 Commits
Author SHA1 Message Date
Jinwoo Hong 0817476b2c fix(mobile): follow-ups from the C1 stack review, one commit per finding (OTA phase C, C1.8) (#21570)
* fix(mobile): encode the host id the native list hands the shell

`web.tsx` encodes the host id into the pathname it opens the shell on; the
worktree-list route beside it still interpolated it raw. `useLocalSearchParams`
answers the decoded value, so a host id carrying `?`, `#` or whitespace builds a
pathname that is no longer one segment.

That shape is not refused where it is built. `matchesRoutePattern` splits on `/`
alone, so `/h/a?b` reads as the single segment `/h/[hostId]` names and the
session starts; the bridge's pathname rule is what refuses it, one `init` later,
and the shell turns that refusal into `document-load-failed`. The route ends on
a failure screen instead of the native list it already has and was about to
render anyway.

The fix sits at the interpolation rather than at the pattern or the bridge,
because the other two are right: the pathname rule is what a path may be, and
the page decodes the segment back when it matches `[hostId]`, so the screen it
opens is the same one. A deep link is the way such an id arrives, which is what
the sibling route's own test already establishes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): end a route segment at the query, not only at a slash

The dot-segment lookahead both route patterns are built from treated `/` and
end-of-string as the only things that close a segment. An href may carry a
query, so the last segment can also be closed by `?`, and there the lookahead
never fired: `/h/..?x`, `/h/%2e%2e?x` and `/h/.?x` all passed
`BRIDGE_ROUTE_HREF_PATTERN` while their slash-terminated spellings were refused.

The sink is `router.push`, and a URL parser resolves `/h/..?x` to `/?x` exactly
as it resolves `/h/../x` to `/x`. That is the climb out of the `/h/` prefix the
rule exists to stop, reached through the one punctuation the rule did not treat
as a boundary.

Fixed in `BRIDGE_ROUTE_SEGMENT_SOURCE`, which is the single place the segment
rule is written and the reason the two patterns cannot drift apart. The pathname
pattern is unaffected: a `?` fails its character class wherever it appears, so
widening the boundary cannot let anything new through there. The existing
segment-rule block gains the query-terminated spellings beside the ones it
already pins.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say which notifies actually reach the mount-order throw

The header said a call before `init` is a mount-order bug and throws, and named
`notifyPageFault` as the one exception. Two more never reach that throw: a grant
is read off the session, so before `init` there is no grant either, and the `&&`
in `navigate` and `storage` short-circuits before `post` can require one.

The code is right and the comment was not, so the comment is what changed. False
is already these two members' refusal answer — it is what they give a shell that
withheld the grant — and both callers handle it. `useRouteHandoff` calls
`notifyNavigate` uncaught inside `push` and falls back to routing inside the
page, so making this path throw would turn an early tap into an unhandled error
in a handler nobody wrapped, which is the same reason the close path answers
inertly rather than throwing.

Pinned rather than left to the prose: the two gated notifies answer false and
post nothing before `init`, the two ungated ones still throw, and the gated ones
post once the shell has granted them. Not a red-first test — there is no defect
here to reproduce — but the contract now has a test holding it in place.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): re-arm the shell session the route rebuilt, not only the host

Two effects share one session. The first rebuilds it from `hostId` and
`routePathname`; the second is the only thing that ever tells the reducer what
the gates say, and it listed the host alone. A fresh session starts in
`checking` and moves on nothing but `gates-changed`, so a route that changed
under an unchanged host and unchanged gates threw the old session away and left
the new one with no effect to run and no verdict to wait for.

`routePathname` joins the gates effect's dependency list, beside the `hostId`
that is already there for the same reason: both are what rebuild the session
above, so both have to re-arm it. Fixing it in the dependency list rather than
by having the reducer restart on a repeat verdict keeps the reducer's rule
intact — a repeat verdict genuinely is nothing new — and keeps the coupling
stated where the coupling lives.

No caller can reach this today: both routes derive the pathname from the host
id, so the one cannot change without the other. The test drives the hook
directly and holds the invariant the wiring is supposed to have, since the thing
protecting it was a property of the call sites and not of this hook.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): persist the last-visited worktree through the mirrored writer

`writeLastVisitedWorktree` noted the write on the mirror and then dropped the
store's promise with `void`. The mirror reports the key as written the moment it
is noted, so a store that refuses the write leaves a value the page is handed on
every `init` and that nothing ever persisted, and the rejection escapes as an
unhandled one because no caller above it holds a catch.

`writeMirroredStorage` in the same module is already exactly this: note first,
persist second, and swallow the rejection deliberately, because a pin that
failed to persist is not a reason to take the workspace off screen. This writer
had grown its own copy of that pair without the last part. Reusing it rather
than adding a local `.catch` is what stops the two copies drifting again, and it
is the boundary that owns the relationship between the mirror and the store.

The test drives a store that refuses the write and listens for an unhandled
rejection, which is the failure the `void` produced and the only way to observe
it from inside a test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): pin the re-arm oracle, and move the query rationale to the rule

Two review nits from round 1, neither changing behaviour.

The re-arm test asserted the session was no longer `checking`, which a failure
state satisfies just as well as a recovery does — the test would have passed on
the opposite of what it is for. It now pins `native-route`, which is the state a
re-armed session actually settles on here: `/h/host-1/tasks` is not the route
the bundle lists, so the reducer answers with the native screen.

The sentence about `?` closing a segment sat in the doc block for the `init`
pathname bounds, which opens by saying that pathname carries no query. Read
top to bottom the block contradicted itself. The rationale belongs beside
`BRIDGE_ROUTE_SEGMENT_SOURCE`, where the shared rule is written and where the
reason is legible: an href carries a query even though a pathname does not, both
are held to the one segment rule, and widening its boundary cannot loosen the
pathname pattern because a `?` fails that character class anywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state what the native router really does with a dot segment

Round 2 review, comment text only.

The rationale beside `BRIDGE_ROUTE_SEGMENT_SOURCE` claimed `/h/..?x` resolves to
`/?x`, borrowing the climb `history.replaceState` performs on the `init`
pathname. That is the wrong sink. An href's sink is the native router, and
expo-router's `resolveHrefStringWithSegments` normalises only an href beginning
with `.`; a rooted one is passed through, its query stripped, and the forked
`getStateFromPath` then matches segments literally against the route patterns. A
dynamic segment compiles to `([^/]+\/)`, which takes `..` as happily as any
other value.

So the harm is not a climb and it is not Unmatched either: `..` is read as the
`[hostId]` a screen is opened for, and the shell opens a host screen for an id
no host has. A different wrong screen from the slash-terminated spellings, and
the same reason one rule covers both patterns. The boundary and the test that
pins it are unchanged; only the sentences describing them are.

The notify header opened by saying three of the four share one guard, one
paragraph above the one explaining that only two ever reach its throw. It now
says both in the same breath.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 23:26:28 -04:00
Jinwoo Hong 030a1e0c77 fix(relay): stop holding a cell row across the whole control accept (#21563)
* fix(relay): stop holding a cell row across the whole control accept

The cell accept path took the host's cell row FOR UPDATE at its first
supersession statement and held it to COMMIT across a dozen round trips,
which capped a cell far from Postgres at a couple of accepts a second.
Fold every cell-row change on the path into one conditional delta write
issued last, so the contended row is held only across the commit.

* fix(relay): give relay_cells one global row lock order, taken last

Moving the accept's cell-row write to the end of its transaction put it
after the host's relay_control_connection_reservations rows, while every
director path that reads the inventory took those rows the other way
round. Pin one order for both roles -- host rows, then the shared cell
row -- by locking the host's reservation rows before the inventory in the
nine director paths that take both, document the tiers next to
CellInventoryLockMode, and add a census that fails on a new path taking
relay_cells first.
2026-09-18 23:16:15 -04:00
Neil 545f526c31 refactor(editor): land shared Markdown scanners on main (#21565)
* refactor(editor): introduce shared Markdown code scanners (#21554)

* refactor(editor): add shared Markdown scanners

* test(editor): verify standalone scanner boundaries

* fix(editor): preserve line endings and fenced code boundaries

* fix(editor): keep bare dash lines out of table scanning
2026-09-18 20:04:31 -07:00
Neil 2038376d8e fix(terminal): a park must not discard the only copy of a remote pane's scrollback (#21285)
* fix(terminal): keep a client copy of a parked remote pane's scrollback

A remote-runtime pty's bytes never transit the client's main process, so the pane's
xterm buffer is the only client-side copy. The ordinary cold-park unmounted that pane
without capturing it, licensed by TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY — a static
build string that says nothing about whether the host retained this pty's buffer. On
reveal, a host that answers 'no-serializable-buffer' (or stays silent past the request
timeout) collapses to a null snapshot and the pane paints blank: tabs and splits survive,
the scrollback is gone.

Capture before every park, not only the retention-budget force-park, so the reveal has a
copy to replay when the host cannot answer. An unverifiable host answer is not proof the
pane was empty; keep the buffer, never discard it.

Adds ORCA_E2E_FORCE_REMOTE_TERMINAL_SNAPSHOT_UNAVAILABLE so an e2e can reproduce the
host-retains-nothing state, mirroring the existing forced-truncation lever.

* test(terminal): prove a parked remote pane survives a host that answers nothing

The oracle is a token the test types into the terminal before the park and the fixture
echoes back. Nothing replays stdin, so a respawned command cannot reproduce that line —
only the pre-park buffer can. An earlier argv marker passed vacuously for exactly that
reason.

The control ('host retains the buffer') is insensitive to the fix and fails if the harness
never parks, never reveals, or never echoed the token, so the regression case cannot be
green for a harness reason.

* refactor(terminal): validate the paired host terminal RPC shape instead of casting it

The merge-commit consistent-type-assertions gate flags every new `as`. Two were fixture
shapes that a type annotation states directly, and the third hid an unchecked RPC payload —
readCreatedTerminalTab now fails with the shape named rather than surfacing later as an
undefined surface id.

* fix(terminal): let a park capture survive an unhydrated repo catalog

Reading state.repos unguarded threw out of the cold-park effect whenever the catalog was
absent, which would break parking itself. Capture is best-effort evidence; an empty catalog
also fails open in shouldPreserveTerminalScrollbackBuffers, the safe direction for a park.

* docs(terminal): pin why the two unhydrated-catalog fallbacks point opposite ways

shouldPreserveTerminalScrollbackBuffers fails open toward 'remote' because a worktree wrongly
judged local parks with no copy at all. worktree-runtime-owner.ts resolves the same unhydrated
catalog to 'local', which is safe there and would be data loss here. A reader pattern-matching
'fail open' across the two gets one of them backwards.

* fix(terminal): keep a parked pane's scrollback across a reconnect merge

The direct-SSH pull replaces a replaced tab's layout wholesale, and a park capture does not
bump tab.generation — so a just-parked tab is not in locallyPreservedTabIds and the only
client-side copy of its remote scrollback went with the layout it replaced. That is the same
data loss this branch already fixes, one layer down, and it is the layer that decides whether
the fix survives the app update the user actually performed.

Carry the client's leaf-keyed scrollback into the host's layout, filtered to the host's own
root leaves. Structure stays the host's verbatim, so a split it added while we were away still
wins and a leaf it retired still drops its bytes. Local wins a conflict: neither copy is then
the only one, but remote-wins would overwrite the tail captured since the last upload and
propagate that backwards on the next replace-session patch.

Not a generation bump: the pane key is `${tab.id}-${tab.generation}`, so bumping would remount
the pane and destroy the very buffer the capture just serialized, lift the recovery-storm
ledger ceiling, and let a stale local ptyId win through preserveNewerLocalTerminalFields.

* fix(terminal): carry a parked pane's scrollback through the mirrored-layout rebuild

Found in review of this PR by rc-ssh-remoting. chooseRemoteTerminalLayout rebuilds a
mirrored tab's layout from the host's picture and never carried buffersByLeafId or
scrollbackRefsByLeafId forward, though it already receives existingLayout. The host
publishes no scrollback of its own, so ANY session-inventory frame landing between park and
reveal dropped the only client-side copy: the rebuild is bufferless, terminalLayoutEqual
compares buffers so the write is not bailed out, and apply-terminal-records assigns it
wholesale.

Measured before the fix: 336 bytes captured at park, 0 after one forced frame, blank pane on
reveal. After: 411 bytes survive the frame and the reveal repaints.

The e2e passed either way because no frame happened to land in its window, so it was not
covering the destroying event. It now forces one inside the park -> reveal window and asserts
the capture survives it.

An identical fix was written and reverted earlier in this branch as 'no measurable effect' —
that measurement ran on a harness deleting the client profile between launches, so nothing
downstream of persistence could register. It was never actually tested.

* feat(session): add a local-only home for ordinary-park scrollback

localOnlyScrollbackByTabId is a top-level session field, tabId -> leafId -> buffer, that never
rides the remote projection: exportRemoteWorkspaceSession is an explicit allowlist of named
top-level fields, so a new one is omitted for free, whereas anything added to
TerminalLayoutSnapshot is copied whole. It is also outside the two records the mirrored-tab apply
rewrites, so a host inventory frame cannot wipe it.

Registered in every exhaustive session registry ('tabKeyed'), hydrated and scoped like the layout
map, dropped with its tab on close/removal/purge/repo removal/mirrored retirement, copied on profile
transfer, emitted by the incremental patch builder, and capped by pruneLocalTerminalScrollbackBuffers
alongside the shared home — with a per-home test so an uncapped path cannot go unnoticed.

Known ceiling, not widened here: the field routes through the partition router that falls back to
'local' when the repo catalog is unknown at write time (#21295).

* fix(terminal): keep ordinary-park scrollback off the upload, and read both homes through one resolver

The ordinary cold park fires on every workspace hide. Its capture now splits: structure (root,
ptyIds, titles) stays in the shared layout, bytes go to localOnlyScrollbackByTabId. Force-park,
hibernate, sleep and shutdown keep writing buffersByLeafId, because that copy is what a second
desktop cold-restores from; a shared capture clears the local copy so the two homes never hold two
versions of one leaf.

resolveLeafScrollbackBuffers is the only read across the two homes (local wins a conflict: it is
the later write by construction). restoreTerminalPaneLayout no longer reads buffersByLeafId
directly, the capture's merge prior comes from the resolver, and the post-replay release covers
both homes.

Measured with the projection at 20 tabs x 2 panes at the per-leaf cap: the shared-layout shape
exports ~22 MiB per replace-session; the local-only shape exports the bufferless baseline.

* test(sync): pin that the mirrored rebuild carries the client scrollback refs

The carry-through added in f210dece83 keeps scrollbackRefsByLeafId for leaves the host still
names (a ref is the only pointer to a local scrollback file), so the rebuilt layout equals the
stored one and the write bails. The old assertion expected the refs to be dropped and has been red
on this branch since that commit.

* test(e2e): assert where a park's bytes land, and re-point the inventory-frame check at the force-park

Once ordinary parks stop writing buffersByLeafId, the existing survivedInventoryFrame assertion
passes trivially — there is nothing in the layout to wipe. The ordinary scenarios now assert the
store-level upload contract (bytes in the local-only home, shared home empty) and that the local
home is out of a host frame's reach; a third scenario reaches a force-park (host without paired
parking, client retention limit 1) and asserts the shared capture survives the forced frame, which
is where the mirrored-layout carry-through is load-bearing.

* test(e2e): leave the force-park reveal out of the assertion instead of matching it loosely

expect.any(Boolean) reads as a check but cannot fail. The force-park reveal is served by the host
tail on a host without paired parking, so it is logged for diagnosis and the assertion carries the
three fields that are deterministic on this topology.
2026-09-18 19:49:38 -07:00
Jinwoo Hong e2afb5eef9 feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) (#21503)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4)

Three gaps the census named, and the last of them is why the page needed a second
grant.

`expo-secure-store` is `{}` on web, so the page's own `loadHosts()` answered with
an empty array and the list painted "Host not found" over the host the shell had
just opened it for. `init` gains an optional `host`, and `host-store.web.ts`
serves it: the profile the screens read, with no device token and no public key,
because the bridge already carries the connection those would have opened. The
native writes it cannot make — pairing, renaming, recency — settle rather than
throw, since recency orders a list the page never shows.

AsyncStorage's web build is `window.localStorage`, and the page has none worth
having: Android turns DOM storage off and on iOS the origin host is the session
id, so a pin set in the page was gone on the next remount. The builder aliases
the module to a page store whose values are the app's own — `init` primes the
allowlisted keys, a write is applied locally and posted over a new `storage`
grant, and the app is where it lands. The allowlist is two keys and is the whole
fence: everything the app stores shares one namespace, the hybrid shell flag
included, so a page that could write any of it could turn the feature on for a
build that never offered it. A key outside the list is refused and, crucially,
not kept locally either — a pin that looks set and is not is the failure the
grant exists to avoid.

The bridge host is built only once both have been read, because `init` is
answered once per `ready` and carries them: a host that started without them
would have to be torn down to carry them, and the list would already have mounted
against a host it could not name.

`Alert.alert` on a failed host removal is a silent no-op in React Native Web, so
inside the page that failure had no surface at all. It routes to the error the
list already shows, on both platforms.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the storage-write ref after the commit

The last render-phase ref write in this hook joins the commit-phase effect.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the confirm setter this callback already calls in its deps

A `useState` setter is stable, so the identity of the callback is unchanged; the list now
says what the body reads. Reported on the line this branch rewrote.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a failed action off the whole screen and the page's store honest (OTA phase C, C1.4)

Five, from one round of review.

A removal that fails no longer writes the identity error: that one is an early return
over the header, the list and the overlays, with nothing to dismiss it and nothing left
to render the confirm it re-opens. It goes to a dismissible line above the list instead,
on both platforms, cleared by the next confirmed refresh.

`init` reads the allowlisted keys on every answer rather than capturing them at mount, so
a document that reloads inside one mount is primed from after its own writes. The read
stays synchronous: the page refuses every member until `init` lands and the golden
recorder mounts a screen in the same turn it drains one, so a promise here moves the
first render of every bridged replay.

A profile read that rejects is now a shell failure with a diagnostic instead of a `ready`
session with no host behind it and a page asking forever. The page bounds a value by the
envelope's own constant rather than caching what the wire drops. And a write is held to
the keys this page was handed, so one host's page cannot rewrite another's pinned list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): state the two test fixtures' types instead of asserting them

The casting gate reads a `SAFETY:` rationale off the line directly above the assertion,
and a wrapped comment puts a comment there instead. Two of the four were not assertions
worth keeping at all: a hoisted fixture says its own type, and the router comes from the
mock the file already installs.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert the two fixtures in place, not through a widened binding

`const x: unknown = …` followed by `x as T` is the widen-then-assert the anti-slop gate
refuses, and rightly: the evidence is discarded and then invented again. The assertion
belongs at the literal, with its rationale on the line above it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the key a refused page write reached for

The last diagnostic still falling through to the line about a view outliving its
host, on the branch that added it. The key is the evidence: it says which host's
pinned list the page was reaching into.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the ref-refresh probe this branch's storage writes too

Same merge, one branch further: a probe here also collects what the page asked
the screen to write.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the web shell route entry

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hand the page the app's storage as it stands, not one init late

The page is handed its allowlisted keys on every `init`, built synchronously, and the app writes
two of them from its own native screens. The shell's map was only re-read after a ready, so a
native write between two readies reached the init after next: the drawer opened on the repo the
user left. The map is now module-scoped and every writer of an allowlisted key notes it as it
writes, so the init that answers a ready already carries it. The store read only seats the map,
and a read that started before a write no longer puts the older value back.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): find the banner's dismiss without an assertion

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): keep the storage mirror in the storage tree

The app's own writers had to reach into `src/mobile-web-shell/` to note a write, which is the
dependency the wrong way round: the shell is what is built on the app's storage, not the other way.
The mirror moves to `src/storage/` and no longer knows which keys the page is allowed; the caller
names them on every read and every seat, so the allowlist stays where it is enforced. No behaviour
change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 22:20:49 -04:00
Neil 56e5cd5e34 fix(editor): render CJK-adjacent emphasis in Markdown preview 2026-09-18 19:15:45 -07:00
Neil d8cfe374a3 refactor(usage): share attribution and scope filtering
Readiness checklist review: PASS. No proven release-blocking findings. Codex and usage tests: 116 passed; all required CI checks green.
2026-09-18 19:14:38 -07:00
Jinwoo Hong ac4dc6599b feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the web shell route entry

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:53:58 -04:00
Jinwoo Hong 084e101328 fix(session-search): keep the title filter while full-text search is off (#21550)
* fix(session-search): keep the title filter while full-text search is off

The search box switched to index search the moment the query was non-blank,
so on a computer with indexing off the panel showed only the consent card and
hid every session. Derive the mode once in useAiVaultPanelSearch: while local
consent is pending the box stays the legacy title filter and the consent card
becomes an offer above the filtered results.

* test(session-search): cover the legacy title filter while indexing is off

The panel test fails on the pre-fix code with no session rows rendered.

* refactor(session-search): drop Not now and name the query flag queried

The dismiss button's only remaining job was wiping the user's live title
filter. Keep localConsent and expose queried so the panel reads one flag per
fact, and assert the post-enable and empty-box states.

* refactor(session-search): guard first and name the search-mode flags for what they mean
2026-09-18 21:45:43 -04:00
Jinwoo Hong b8f3b1ec00 feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) (#21501)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the web shell route entry

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:21:48 -04:00
Jinwoo HongandClaude 7080eb0604 fix(relay): bound the idle-rehome candidate poll to a window of decisions (#21557)
* fix(relay): bound the idle-rehome candidate poll to a window of decisions

The director's idle-regional-rehome poll built every (eligible host x target
cell in its preferred region) pair, applied the cohort predicate downstream of
that fan-out, sorted the lot, and took LIMIT 100 OFFSET n. Its cost was set by
the size of the fleet and the width of the cohort, so raising the cohort from
10% to 100% pushed it past the serving pool's 5 s statement_timeout and the
rollout stalled at 0.37 hosts/min.

The poll now resolves the cell inventory once (tens of rows), takes a bounded
window of decision rows in primary-key order from a keyset cursor with the
cohort, freshness and cross-region predicates applied first, verifies only that
window against the host-side gates, and ranks targets in the process. Same
candidates in the same priority order; the work per poll no longer depends on
the cohort or the fleet.

Adds a once-a-minute aggregated poll summary so an operator can tell a poll
gated by the dispatch budget from one that found nobody to move.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(relay): pin the rehome verification to the window's exact keys

The window read and the verification read take separate snapshots. The
verification repeated the window's predicate with its own LIMIT, so a decision
that turned eligible between the two reads shifted that LIMIT and pushed the
window's last host out of it -- while the cursor still advanced past that host,
skipping it for a whole sweep.

The verification now names the keys the window returned. Its LIMIT stays as the
optimisation fence that stops Postgres flattening the subquery, but can no
longer truncate a key set that is at most one window long.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-18 21:16:19 -04:00
github-actions[bot] 742fa638f3 Update README downloads badge 2026-09-19 00:58:02 +00:00
Jinwoo Hong d043cfbbee feat(mobile): the page mounts on the shell's init, with the client injected and a fault boundary (OTA phase C, C1.1) (#21500)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 20:45:03 -04:00
Neil cd81725d70 feat(terminal): configure URL click and middle-click behavior (#21438)
* feat(terminal): configure URL click behavior

* fix(i18n): include terminal link setting title

* fix(i18n): localize terminal click controls

* fix(settings): update terminal URL click title
2026-09-18 17:31:06 -07:00
Jinwoo Hong f2fd18c820 test(mobile): make the bridged recording-corpus replay a CI gate and pin the C1 page closure (OTA phase C, C1.6) (#21533)
* test(mobile): the bridged corpus replay is a gate, not an opt-in flag (OTA phase C, C1.6)

`rpc-recording-through-bridge.test.ts` replays all 787 goldens through the page
bridge and pins how far that bridge is from byte-identical. It only ran when
`RPC_FOUNDATION_BRIDGE=1` was set, which CI did in a step of its own. A gate
whose CI job has to remember to set a variable is opt-in, and a branch that
widened the divergence with that step edited away would have been measured by
nobody.

The suite now runs by default and `RPC_FOUNDATION_BRIDGE=0` skips it, for a
local run that does not want the three minutes. `BRIDGED_PARITY_OFF` names the
one value that skips, so an unset or mistyped variable still runs the gate.

The dedicated CI step goes with it: `pnpm test` collects the file already, so
keeping the step would have run the same 788 tests twice in one job. Vitest
gives the file a worker beside the rest of the suite, so the marginal wall time
is a fraction of the ~3 min it takes alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the bridged verdict of every C1-page-closure golden by id (OTA phase C, C1.6)

The bridged replay certifies the corpus in counts over 787 goldens, and a count
is the wrong instrument for the claim C1 needs. C1 moves one domain to the web:
`app/h/_layout.tsx`, `app/h/[hostId]/index.tsx` and their import closure. A
golden recorded at a call site in there that stopped replaying byte-identically
is paid for by any of the other 684 that started, and every existing assertion
still passes.

`c1-page-closure.ts` names the 22 families and 103 goldens of that closure and
pins each one to the verdict it gives: 54 byte-identical, 49 in four of the five
classes the suite already excludes, all of them recorder observation artifacts
whose wire bytes C0.5 and C0.8 proved identical. Membership is checked per
family, not against the flat id list, so a golden newly derived into a family
this domain owns arrives as a finding instead of being missed for never having
been pinned. A closure golden may only be excluded into a class that carries a
reason in `BRIDGED_PARITY_EXCLUSIONS`.

Two full-corpus assertions go with it. `identical` was a floor; it is now the
exact 787 minus the excluded classes. And the run's own size is pinned to the
corpus: every class is an upper bound, so without that a corpus that lost
goldens outside the identical set satisfied all of them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): move a closure golden to a verdict it is not already pinned to (OTA phase C, C1.6)

The drift test took whichever golden the pin lists first and moved it to
`params-undefined`. Nothing said that golden was not pinned to that class
already, and the day one is, the test asserts a change it never made. Pick the
first golden whose verdict is something else, and assert the pair of verdicts
the line reports rather than just the new one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin `identical` to its baseline, not to the run's own exclusions (OTA phase C, C1.6)

`identical: pinned - excludedCount` took `excludedCount` from the run, and the sum
assertion above it already forces `total(counts) === excludedCount`, so the pair
reduced to `corpus === pinned`: only the size of the corpus was pinned. With every
per-class check an upper bound, a golden moving out of an excluded class into
`identical` satisfied all of them.

`bridgedParityTallyDrift` pins every number to `BRIDGED_PARITY_BASELINE` exactly,
`identical` among them, and its own tests encode the mutation: one
`result-absent-settlement` golden reported `identical` is two drift lines, where
before it was a green run.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): the observation class counts 3, not 7 (OTA phase C, C1.6)

The number in the prose predates the baseline it describes; `result-absent-observation`
is 3 in `BRIDGED_PARITY_BASELINE` and in the run, and the line above it already says
341 / 3 / 6 / 33 / 8.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the checks that are still there, not the bound that is gone (OTA phase C, C1.6)

Three comments described the per-class upper bound the tally replaced. What the
exact pin is exact *against* is now `unclassified`, the exclusion sum and the
membership pins, so say those.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the closure-exclusion check the cheap suite already makes (OTA phase C, C1.6)

`c1PageClosureExclusions()` reads `C1_PAGE_CLOSURE` and nothing the run produced, so
asserting on it inside the three-minute replay bought nothing the cheap suite next
door does not already buy. `c1-page-closure.test.ts` makes the same assertion with a
presence precondition on top, which is the stronger of the two.

The comment beside the tally counted two checks above it; there are three, and a
golden that moved out of an excluded class into `identical` is invisible to all of
them: `result-absent-settlement` is past the nameable cap, so membership drift never
looks at it, and lowering it lowers the exclusion sum with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 19:56:27 -04:00
09073086a8 feat(terminal): inline images via @xterm/addon-image (perf-first) (#19512)
* feat(terminal): inline images via @xterm/addon-image, perf-first

Add opt-in inline terminal images (SIXEL, iTerm2 IIP, Kitty graphics)
through @xterm/addon-image, designed to keep idle terminals unaffected.

Performance:
- The addon (base64-inlined wasm decoders + protocol handlers) loads off
  the boot critical path via a deferred loader that mirrors the WebGL
  addon: primed after first paint only when the setting is on, read back
  synchronously at attach, with a 3-attempt cap so a transient failure
  never disables images for the session and a missing chunk never
  refetches per pane. renderer-boot-graph guards against eager import.
- enableSizeReports:false so the addon never sets windowOptions and
  double-answers Orca's own CSI 14t/16t responder.
- Perf-tuned decode/storage limits (storageLimit, sixel/iip/kitty size
  caps) in one place.

Correctness:
- Orca's DA1 handler wins over the addon's (last-registered-first), and
  the default DA1 response never advertised Sixel (;4), so DA1-detecting
  tools (chafa, img2sixel, viu, timg) never emitted it. The winning
  handler now appends ;4 while the setting is on, resolved per query so a
  live toggle changes the next DA1; idempotent against the ConPTY
  response that already lists it.
- ORCA_IMAGE_PROTOCOL=kitty is exported to spawned shells (local, daemon,
  relay/SSH) and forwarded across the WSL boundary, so image-capable
  agents can pick an encoder. Unknown image sequences are swallowed by
  xterm when the addon is detached, so this never garbles output.
- Settings toggle (default on) gates rendering and DA1 advertisement.

Cross-checked against community PRs #7775, #11706, and #19201 at the end;
credited below.

Co-authored-by: s546126 <s546126@users.noreply.github.com>
Co-authored-by: XRX193 <XRX193@users.noreply.github.com>
Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com>

* fix(terminal): bound inline image memory and classify Kitty replies

* fix(terminal): bound image decode and release image resources on cleanup

* fix(terminal): address image addon review feedback

* test(terminal): stub setPaneInlineImagesEnabled in appearance manager fakes

* fix(terminal): evict unplaced kitty payloads before displayed images

Byte-budget eviction dropped the oldest transmitted blob regardless of
placement, so a new upload could erase a visible image while abandoned
blobs still held budget. Unplaced payloads now go first and displayed
ones only when that is not enough. The incoming image is always stored,
so an oversized one overshoots the cap by one payload instead of being
dropped after the protocol already acked OK.

* fix(terminal): gate DA1 Sixel on real addon attachment; claim SSH image spec in CI

- DA1 advertised Sixel from the setting alone, so a pane whose lazy addon
  chunk was still loading (or had failed all three attempts) told
  feature-detecting tools to emit DCS that nothing could render. Track the
  attached decoder per terminal and require it before setting the ;4 bit.
- tests/e2e/terminal-inline-images-ssh.spec.ts was Docker-gated but claimed
  by no lane runner, so pr-e2e-gate-contract failed and the spec would have
  self-skipped green forever.
- Reject non-positive PNG IHDR dimensions before decode: they are parsed with
  signed shifts, so a dimension >= 0x80000000 came back negative and slipped
  past the pixel-limit comparison.
- One resolveTerminalInlineImagesEnabled() for the default-on setting; the
  four call sites mixed '?? true' with '!== false', which disagree on null.
- One readInlineImageResources() walk of the addon internals instead of two
  copies that could drift against the patched dependency.
- Isolate the deferred-attach drain per pane; make the zoom-invariance and
  backing-storage e2e assertions fail when the feature is dead.

* refactor(terminal): one lazy xterm addon loader for webgl and image

terminal-image-addon-loader was a structural clone of the webgl one — same
memo, attempt cap, and .then(ok,err)-clears-memo recovery. Both now wrap
createLazyXtermAddonLoader; each keeps its literal import() specifier so the
bundler still splits the chunk (verified against a fresh build: addon-image
stays out of the boot graph).

* refactor(terminal): name openTerminal's addon flags; pin image addon limits

Two adjacent optional booleans could be swapped without a type error once
inline images added the second one.

* docs(terminal): state the real per-pane image ceiling; drop test ordering dependency

storageLimit:32 reads like the pane's budget but keys three pools — decoded
pixels, retained encoded Kitty blobs, and pending WASM decoders — so the worst
case is ~98 MB per pane with no cross-pane governor. Say so at the constant.

pane-inline-images.test.ts's deferred case needed to run first; it now takes a
fresh module instead, and the rest prime in beforeAll. Verified by running the
file with that test moved last.

* fix(terminal): satisfy rebased static analysis gate

* fix(terminal): complete casting gate cleanup

* fix(terminal): recover failed image addon loads

* fix(terminal): bound image decoder allocations

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: s546126 <s546126@users.noreply.github.com>
Co-authored-by: XRX193 <XRX193@users.noreply.github.com>
Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 16:32:49 -07:00
NeilandVincent 9ebfd2e8ae Reapply "feat(composer): choose a base ref in the New Workspace composer" (#21536) (#21543)
This reverts commit a7b9ea5995.

Co-authored-by: Vincent <47273853+Tkotm76@users.noreply.github.com>
2026-09-18 16:00:26 -07:00
Neil a7b9ea5995 Revert "feat(composer): choose a base ref in the New Workspace composer" (#21536) 2026-09-18 14:58:38 -07:00
Jinwoo Hong 164c7140fd fix(relay): stop reporting an unavailable home cell as exhausted capacity (#21518)
A host whose home cell is not live — readiness false, drained, or inside a
boot window — is refused by the committed-fence branch in assignOnce()
without any capacity being consulted. It answered relay_capacity_exhausted,
so every cell boot and every readiness dip printed capacity rejections at
17% fleet utilisation and sent an investigation after headroom that was
never short.

The branch now raises RelayHomeCellUnavailableError, which carries the cell
id and which of cellIsLive()'s conditions failed (draining / booting /
unheard / not_ready). The director logs reason, cause and cell, and returns
the new reason in the same retryable 503. Nothing on the wire reads the
body: the desktop client discards it unread and branches on status only,
and no log-based metric or alert parses the reason. The load harness, the
only body-reading consumer, gets its own bucket so a home-cell rejection no
longer inflates the capacity count.

Hinted grants are now logged on whichever lane served them, so a host that
failed sticky verification and was rehomed by placement leaves a record of
where it landed. Unhinted placement grants stay silent.
2026-09-18 17:55:35 -04:00
Jinwoo Hong 3467e5f6b5 fix(relay): check the rehome dispatch budget before planning the candidate join (#21517)
* fix(relay): check the rehome dispatch budget before planning the candidate join

`selectIdleRegionalRehomeCandidates` read the enable control and the fleet
safety snapshot, then ran the twenty-table candidate join, then handed every
row to the worker, which POSTed each one to its source cell. Only there — in
`commitIdleRegionalRehome`, three statements into a write transaction that
takes `FOR UPDATE` on two global single-row tables — was the durable dispatch
budget consulted.

The budget is ten moves a minute (`next_dispatch_at = now + 6s`), and five
directors poll every six seconds, so most of that work was spent to be told
the budget was closed. A five-minute `paused_until` made every poll in the
window do it.

The gate is a single-row primary-key read, so it goes in front. An absent row
means the budget has never been spent and opens the gate, matching the
INSERT ... ON CONFLICT DO NOTHING the commit path already relies on.

* test(relay): assign the closed budget field once so the case runs on Postgres

The two gate cases zeroed both `next_dispatch_at` and `paused_until` and then
set the one under test, which names that column twice in a single `SET`. SQLite
accepts it; Postgres raises "multiple assignments to same column", so both cases
failed whenever `ORCA_IDLE_REHOME_POSTGRES_URL` pointed the suite at a real
server -- exactly the backend the gate has to hold on.

Setup already leaves both fields at 0, so naming the other one bought nothing.
2026-09-18 17:55:27 -04:00
Jinwoo Hong ce5d8c02d4 fix(relay): wait out a cold proxy at boot instead of exiting the cell (#21516)
* fix(relay): wait out a cold proxy at boot instead of exiting the cell

A cell container starts its relay process beside a cloud-sql-proxy that is
itself still dialling. The first pool acquire therefore competes with a proxy
cold start, and the 2s connect timeout that protects the request path fires
before the proxy is listening. `openRelayDatabase` rejects out of the region
backfill, the top-level await rejects, and the process exits; COS restarts the
container and the next boot succeeds 1-3s later. The 2026-09-18 fleet roll saw
0-7 of these per cell, including on cells with zero hosts, so it is a property
of the boot sequence rather than of database load.

The boot open now retries on transient errors only, inside a 45s wall-clock
window with exponential backoff from 250ms to 4s. The classifier is the one the
request path already uses, so a rejected credential or a bad URL still exits on
the first attempt. Each wait logs `orca_relay_boot_database_retry` and a
give-up logs `orca_relay_boot_database_failed`, both with the bounded error
category, so a rollout can tell a slow boot from a stuck one without reading
container exit codes.

The bounded startup retry is lifted out of `reconcileCellAdmissionAtStartup`,
which had the same loop; its attempt budget, flat delay, and both log events are
unchanged (a flat delay is a cap equal to the base).

* fix(relay): retry the boot open only when Postgres is unreachable

The boot open re-runs the schema apply, and applyPostgresSchema refuses to
repeat a DDL lock timeout on purpose: relation locks are granted in queue order,
so a repeat parks every writer behind the same statement again. Gating the boot
retry on the full request-path classifier would have re-queued it up to 16 times
in 45s on sustained 55P03 - the mechanism behind the 2026-09-16 outage.

The boot call site now has its own predicate: pool connect failures (both
connect-timeout messages and an acquire-marked early-ended socket) plus 08001
and 08006. Lock and overload SQLSTATEs - 55P03, 57014, 53300 - exit on the first
attempt. The retry predicate moves onto the policy because what a step re-runs,
not the request path, decides what it may repeat; the startup reconcile keeps
the full classifier, which is what lets it wait out 55P03.
2026-09-18 17:55:19 -04:00
Jinwoo Hong 3336933cc8 fix(orchestration): list worker Dispatches newest first and warn when the page truncates (#21523)
* fix(orchestration): list worker Dispatches newest first and warn when the page truncates

`worker-list` paged `ORDER BY d.rowid ASC` with a 100-row cap, so a Run with
more than 100 Dispatches answered with its OLDEST 100. The workers a coordinator
had just started, and the rows carrying `projection.attention.requiresAction`,
were on a page nobody fetched, while `counts` and `page.total` covered the whole
Run so the receipt read as complete.

One ordering, flipped: the detail query and the terminal-state scan it pages by
both order `d.rowid DESC`, and the cursor fence walks down (`d.rowid < anchor`).
The snapshot fence is unchanged — `d.rowid <= snapshot` still means "nothing
created after the first call".

When the page truncates the receipt now carries a `warnings` string, the same
shape `worker-output` already uses, alongside `page.hasMore`. Text output keeps
its `More: --cursor` line and prints the warning through the block it already
had for partial-host errors.

Refs STA-7861

* fix(orchestration): make the worker-list truncation warning true on every page

The warning said "Showing the N newest of T Dispatches" unconditionally, but
`hasMore` is true on every page except the last, so page 2 of a 300-Dispatch Run
claimed to be the newest 100 while showing rows 200..101. This PR exists because
a receipt read as complete when it was not; that warning shipped a receipt that
read as the newest page when it was not.

The page count and the ordering are separate facts, so state them separately:
"Showing N of T Dispatches, newest first; more are on later pages." True on page
one and page N alike, no extra state. The 105-row case only ever reached the
last page, where `hasMore` is false, which is why it missed this; a new case
walks 6 Dispatches at `--limit 2` so a page that is truncated AND not page one
is covered.

Also: the `worker-list` --help note and the recovery-and-cleanup reference still
described the oldest-first contract; both now say newest first. The snapshot
test is renamed to the property it actually proves — under DESC a later insert
is unreachable by arithmetic, so what the `d.rowid <= snapshot` fence still
earns is pinned `page.total` and `counts`, not row exclusion. The continuation
comment says "below the anchor" next to `d.rowid < ?`, and the two SAFETY
rationales now say what they are: an unchanged cast the gate flagged because the
diff moved inside its span.

Refs STA-7861
2026-09-18 17:38:22 -04:00
Jinwoo Hong c5733e812a fix(opencode-usage): count cache-read tokens (#21522) 2026-09-18 17:07:22 -04:00
Jinwoo Hong d253dd0e1d fix(mobile): the page bridge accepts every reply native accepts and settles what it refuses (OTA phase C, C0.8) (#21511)
* chore(mobile): repin the recording corpus to main's tip (OTA phase C, C0.8)

C0.5 pinned `baseline` to its own branch commit, which the squash-merge made
unreachable, so `rpc-recording-pin-guard.mts ancestry` fails on main and
`--record` refuses to run at all. Repin to main's tip and refresh every header
from it.

Header-only, and the corpus proves it: across all 787 goldens exactly two
distinct lines changed, the old `baseline` and the new one. `recorderSha256`
did not move, because nothing under the recorder's own directory did.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): accept at the page bridge every reply the native client accepts

`BridgeReplyPayloadSchema` required `_meta` on both arms. The predicate the
native client applies to a reply off the wire, `transport/rpc-response-shape.ts`,
requires none, and `src/shared/runtime-rpc-envelope.ts` — the envelope clients
and runtimes share — makes `_meta` optional on a failure with a nullable
`runtimeId`. The page's reader was strictly narrower than the transport it
stands in for, so replies the phone accepts today were refused, dropped with a
diagnostic, and settled nothing.

The reader is now that predicate itself, imported rather than restated: one
module owns the shape, and a widened reader is safe in both directions. `{ ok:
true }` with no `result` key stays refused, because `isRpcResponse` drops it too.

Killer test: `the reply reader is the native acceptance predicate` compares the
page's verdict against `isRpcResponse` over eleven payloads; six of them were
red before this change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): settle the exchange a refused bridge frame was answering

A host frame the page's reader refuses was reported and dropped, and the
request it answered stayed pending for the life of the document. A screen has
no recovery from that: `sendRequest` never settles, so nothing rerenders and
nothing retries.

The page now salvages the id out of the refused frame, through the same caps
the reader applies, and settles it — but only when it already holds that
exchange, so a salvaged id reaches nothing the page did not open itself. A
request rejects with `BridgeReplyRefusedError`, which carries the refusal and is
now marked delivery-unknown at construction: the shell answered, so the desktop
has already run the request and a caller told this was a definite failure would
offer to retry what already happened. A subscription ends the way a refused
`subscribe` ends today.

`bridge-rpc-client.ts` was at the 300-line ceiling, so the inbound routing moved
to `bridge-client-inbound-frames.ts` and the diagnostic vocabulary, which both
sides raise, to `bridge-client-diagnostics.ts`. No `max-lines` disable.

Killer tests: `settles the request a reply it could not read was answering, on
the same turn` (hung to a 5s timeout before) and `ends the stream an event it
could not read belonged to`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name what the bridged replay still excludes, and ratchet the pin

The class this harness was landed to name is closed, so the pin says so:
`reply-meta-required` is 0 and has no exclusion reason, which is what
`divergence-classes.test.ts` now requires of any class the run is allowed to
count. `BRIDGED_PARITY_EXCLUSIONS` gives each remaining class the reason it is a
bound on the claim rather than a defect, the run prints the excluded total
beside those reasons, and one assertion states the whole claim: nothing diverges
that no predicate has named.

Measured, not argued, for `params-undefined`: all ten scenarios that send an own
property valued `undefined` already record the key as absent from the bytes
their request put on the wire — `tw-smart-search-all-providers` records
`{"filter":"assigned","limit":50}` with `workspaceId` already gone — so the
bridged run sends the identical frame. What differs is the object
`ScriptedRpcTransport.complete` matches a scenario step against, one level above
any serialization. The projection named as the fix is not where it lives:
`projectMobileRpcRequestParams` rewrites `worktree.ps` alone, none of the ten
calls it, and the bridge host forwards into the same `StableLogicalRpcClient`
the native screens hold, so there is no shell-side copy to move.

`divergence-evidence.test.ts` asserted the narrow reader that the `_meta`
widening removed; it now pins that the counterfactual changes no verdict, which
is what makes it a detector for that reader coming back.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say the remaining classes total 391 in one line

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read the throw a bridged golden died on, not just the scenario

The `params-undefined` arm named a class from two facts that never met: the run
threw, and the scenario somewhere scripts a param key valued `undefined`. Any
failure inside those ten scenarios was that class, reported by nothing. A seeded
wire bug — one extra own key on every request's params — put 627 goldens in
`unclassified` and still left `params-undefined` at exactly 33, all corrupted;
scoped to `linear.listIssues` alone it stayed green.

The evidence now carries the step the scripted transport refused, the paths the
scenario values `undefined` on it, and every path where the params that arrived
differ from the ones it scripts — read off the frames the page posted, not off
the message. The class needs every path that moved to be one of the scripted
`undefined` ones, and at least one to have moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): cancel the stream a refused frame belonged to, not just end it

Ending a stream deletes the page's record and posts nothing back. That is right
for `end` and `error`, where the shell has already retired it, and wrong for a
frame this reader refused: the shell is still serving that stream, so later valid
frames on the id were dropped with no diagnostic, no ack went back, and the only
release left was the host's overflow backstop, which counts unacked frames and so
never fires on a stream that has gone quiet. A newer shell adding a member to a
closed list — a new `binary.format`, say — lands exactly there.

`abandon` posts the cancel and tells the listener; `failExchange` picks it over
`end` for the one path where the shell has not let go.

Six goldens move to a new named class for what the cancel changes in the replay.
The unsubscribe it publishes is a physical payload the native run has no
counterpart for, and it takes the recorder's next occurrence name for that
method, so the scenario stops matching before there is a recording. Four came
from `result-absent-observation` and two from `write-ordinal`; the sum over the
corpus is unchanged and nothing stopped replaying byte-identically.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): hold the bridged pin to the size of the corpus

Each class in the pin is an upper bound and `identical` a lower one, so nothing
stopped a single class being loosened on its own: raising `params-undefined` by
one passed every assertion the flagged run makes. The comment already claimed the
total was fixed at the corpus; this counts the goldens on disk and checks it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name the two refusals that can settle nothing at all

`oversized` is decided on the raw string and `malformed-json` on a parse that did
not finish, so neither frame ever yields an id and neither can settle the
exchange it was answering. Nothing on the page settles those: `close` or a shell
replacement releases the slot, and otherwise it is held for the life of the
document. Neither arises from a host that is behaving — it chunks at the frame
cap and answers a body over `BRIDGE_MAX_REPLY_BYTES` with an `error` frame — but
the boundary was unstated and untested, which is how it reads as covered.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say which way each number in the bridged pin may move

The comment said the pin moves down and never up, two lines above a class that
went from 338 to 341 when a fix changed which difference a run meets first. Both
are true of different halves of it, so say which: `identical` only moves up, a
class only moves down, and two excluded classes may trade members in one edit
that leaves the sum alone. The sum is now checked, so that trade cannot hide a
golden that stopped replaying byte-identically.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin which goldens are in a class, not only how many

A count is blind to a trade. Every predicate reads the scenario rather
than the frame the page refused, so a golden that started refusing for
real can walk into an excluded class while another walks out, and the
counts, the sum and the `identical` floor all still hold.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 16:52:53 -04:00
Jinwoo Hong 73a58bd21a feat(session-search): resolve Workspace and Project scope on the host (#21509)
* refactor(session-search): move the AI Vault project key to shared

The host must spell a project key exactly as the client does, so the two
sides share one function instead of two copies that can drift.

* feat(session-search): add a scope identity to the search request

The panel cannot keep translating a project into one path per worktree: a
repo with 580 of them exceeds the 64-path cap and the search fails outright.
The request now carries the scope's identity instead, and a host acknowledges
the scope it resolved so a client can tell a scoped answer from an old host's
unscoped one.

* feat(session-search): resolve a scope identity on the host that answers

Every entry point already funnels into searchSessionService, so the identity
becomes paths there once: native, WSL, SSH and relay hosts cannot disagree.
A host that does not know the workspace or project answers scope-unknown
rather than widening the search to everything it has.

* test(session-search): pin how a host resolves a scope identity

Covers prior paths, a workspace another now claims, folder workspaces, a
custom worktree base path, flat placement where the global root belongs to
every project, and the 580-worktree fold the panel's path list could not do.

* fix(session-search): type the scope store by what the catalog reads

A full Repo/Project/ProjectHostSetup requirement forced test stores to stand
up rows the catalog never looks at.

* feat(session-search): send the scope identity from the panel

Workspace and Project name what to narrow to; All sends nothing. A host that
answers a scoped search without acknowledging it is reported as needing an
update, and none of its hits are shown, because they are not this scope's.

* test(session-search): pin the new-client-against-old-host skew

An old host strips the identity and answers with every session it has, and
the answer is well-formed. The missing acknowledgement is the only evidence,
so the merge drops those hits and names the host instead.

* test(session-search): pin the identity and acknowledgement across every entry point

IPC, the runtime RPC method, the relay handler and the shared remote client
each carry the identity out and the acknowledgement back, and the relay -- which
has no repo catalog -- reports the scope rather than widening the search.

* fix(session-search): acknowledge the scope on an all-computers merge

The merge built its results without the acknowledgement, so the renderer read
it as an old host, dropped every hit and asked for an update. That is the
default path: the panel defaults to Workspace and the host scope falls back to
All. Per-host skew is still reported through `hosts`.

Host-resolved paths no longer travel in `filters.scopePaths`. That field is
capped at 64 for the clients that write it by hand, and the scanner child
re-parses the request with the same schema -- so a project whose worktrees do
not share one managed directory failed at 65 paths with "not ready". They ride
beside the request now, where no wire cap applies.

Managed directories come from buildKnownOrcaWorkspaceLayouts, so a workspace
root the user has since moved away from is covered too.

A workspace identity is resolved through this host's own worktree registry
rather than the directory embedded in the client-supplied id.

* test(session-search): follow the service search signature

Host-resolved paths are a second argument now, so the call-shape assertions
that pinned a one-argument call name it.

* fix(session-search): answer consent and readiness before an unknown scope

The registry short-circuited an unresolvable scope before current.search ran,
and current.search is where disabled and not-ready are decided. A host with
indexing off that lacks the project told the user it did not have the
workspace, which they cannot act on. The verdict now travels to the service
beside the request, and the service answers it after its own checks.

* fix(session-search): acknowledge only a scope that resolved

An unknown verdict is still a verdict, and it was being acknowledged as if the
host had narrowed. The skipped banner also counted only 'searched' as having
resolved the scope, so a host that resolved it and came back stale or timed out
let the scope lines reappear where they explain nothing.

* refactor(session-search): drop the version-mismatch receipt

No stable release ships search, so the only hosts that have it and predate
`within` are dev and ad hoc builds. The acknowledgement, the needs-update
outcome and the copy behind it would be permanent dead weight from the first
stable release on. The scope-unknown outcome and the off / not-ready / unknown
ordering stay.

Also trims this PR's new docblocks to the repo's one-line why rule.
2026-09-18 16:46:31 -04:00
Jinwoo Hong fc3a5d7326 chore(mobile): repin the recording corpus to main's tip (OTA phase C, C0.8) (#21510)
C0.5 pinned `baseline` to its own branch commit, which the squash-merge made
unreachable, so `rpc-recording-pin-guard.mts ancestry` fails on main and
`--record` refuses to run at all. Repin to main's tip and refresh every header
from it.

Header-only, and the corpus proves it: across all 787 goldens exactly two
distinct lines changed, the old `baseline` and the new one. `recorderSha256`
did not move, because nothing under the recorder's own directory did.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 14:55:13 -04:00
github-actions[bot] f2f2c37fc6 Update README downloads badge 2026-09-18 18:30:04 +00:00
Jinwoo Hong f24f38bd41 test(mobile): certify the golden corpus through the page bridge (OTA phase C, C0.5) (#21485)
* test(mobile): record the golden corpus through the page bridge (OTA phase C, C0.5)

`ScriptedRpcTransport` gains one seam: an optional `wrapClient`, applied
between the recorder's own instrumentation and the logical client, so a
recording can be driven through another transport without the runner
learning which one. `runRecording` passes it through.

The instrumentation moves outside the seam. `ordinal` orders a logical
`sendRequest` against the recording's device writes and physical payloads,
and the operation makes that call at the same moment with or without a
wrapper; stamping it under one times the wrapper's forwarded send instead,
which the unwrapped recording has no counterpart for. Measured: 131 goldens
move their sender ordinal under the seam, none above it. The name a physical
send is filed by therefore becomes a queue rather than one slot, taken on the
inside of the wrapper — a wrapper that forwards on a microtask arrives after
the next logical call has been made, and one slot hands both sends the second
name. Underflow throws; a wrapper that drops a send is a finding.

`rpc-recording-through-bridge.test.ts` is the harness: every golden recorded
again with `BridgeRpcClient` over an in-memory port pair to a `createBridgeHost`
holding the scripted client, compared body for body against the committed file.
One FIFO per direction, delivery on a microtask, and the `init` handshake
delivered in place before anything mounts.

It is opt-in behind `RPC_FOUNDATION_BRIDGE=1` and does not pass yet. 391 of 787
goldens diverge, for four causes that are findings about the bridge rather than
about the corpus; the suite's header names each one with its count, and flipping
the gate is one line once they close.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the recording corpus and refresh its headers (OTA phase C, C0.5)

The C0.5 seam edits `scripted-rpc-transport.ts` and `run-recording.ts`, both
inside `RECORDER_DIRECTORY`, so `recorderSha256` moves and every golden's header
names an engine that no longer exists. `baseline` moves with it, from
1e3795de99 to ddbb194585, which is main's tip and the tree these were recorded
against; the recorder's fence is clean at that commit, tracked and untracked
alike.

Recorded whole rather than in part, as the README requires: a partial refresh
would leave the corpus pinned to two different trees.

The delta is header-only, and that is checked rather than asserted. All 787
goldens changed exactly two lines each, and across the corpus and the manifest
the only keys that moved are `baseline` and `recorderSha256`, to one value
each. No observation moved, which is the claim the pair of digests exists to
make: the engine changed, what it observes did not.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): correct the bridged-parity counts to what the landed tree measures

The counts in the harness's header were read off an intermediate run: 391 is
the divergence with the reply schema's `_meta` requirement already widened, not
the divergence this tree has. Against the tree as it lands, 763 of 787 goldens
diverge and 24 replay byte-identically; widening `_meta` is worth 372 of them
and takes the count to 391.

The same intermediate run is behind a wrong sentence in this lane's first
commit message, which says no sender ordinal moves with the instrumentation
above the seam. Seven do, plus six payload ordinals, and they are the subscribe
reorder cause 3 now names. 131 was the count below the seam.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repartition the bridged-parity classes from a measured run

The header's last two counts were read off an intermediate measurement. Classified
from the failure text of a run with the `_meta` arms widened, the 391 that remain
partition 345 / 33 / 13, not 340 / 13, and the ordinal class is not a reorder on the
wire: the page posts its frames in call order and they publish in that order, while
the writes the operation makes above the bridge land a delivery earlier.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): let the shared bridge port pair carry any shell client

The golden recorder needs the pair the bridge tests already have, holding its own
scripted client rather than the fake, delivering the handshake in place so a screen
can mount in the same turn, and able to answer one counterfactual: what the page
would have done had the shell posted a field it does not. Every other test keeps the
shape it had under `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): catch a wrapper that swallows a send instead of mislabelling the next

The seam took the next name off the queue without checking it belonged to the method
now on the wire, so a wrapper that rejected without forwarding left its name behind
and the following payload was filed under it. Underflow already threw; this is the
other half, and it is the half that is silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the bridged-parity partition by rule instead of by reading it

Counting the classes by eye is how two of them drifted. Every diverging golden is now
named by a rule over the frames and the scenario, each name is disjoint, and a golden
that fits none of them fails the run. The first class needs a second replay with
`_meta` supplied, because nearly every golden is refused some reply for that field
and only supplying it says which ones the field explains: 372 / 338 / 7 / 33 / 13
over 787, with 24 byte-identical. CI runs it so the counts cannot drift again.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the recording corpus and refresh its headers

The seam's name check and the export the parity suite reads both sit inside
`recorderSha256`, and the port pair the suite now borrows sits inside the recorder's
fence, so the pin moves to this branch's tip and all 787 headers follow. Two lines per
golden, both of them header: baseline and recorderSha256, and no body byte anywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the recorder's own boundary ratchets green

Two of them were red, and the first has been since the seam landed, because that
commit was checked against the recorder's directory instead of the whole mobile
suite. The engine may not import a suite, so the parity classifier moves beside the
recorder rather than inside it, where the golden digest would also stop claiming a
recording's provenance for a rule that only reads failures. And the raw-port ceiling
for the scripted transport rises from five to seven: the seam needs one layer between
the operation's call and the logical client, and its two references are what that
layer costs. A named argument type takes back the third.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the corpus onto the ratchet fixes and refresh its headers

Supersedes the refresh two commits back: the scripted transport moved again, so both
the digest and the pin do. Two lines per golden, both header, no body byte anywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stamp the counterfactual on replies only, not on events

An `event` frame carries a `payload` too, so keying off the key rather than
the type put `_meta` into subscription bytes. The page reads an event payload
as `z.unknown()`, so nothing refused it and the classes did not move, but the
tool that names a divergence must not author one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 13:50:55 -04:00
Brennan Benson 0b57ce0295 fix(attention): count both terminal and chat siblings when clearing workspace unread (#21274)
* fix(attention): count both terminal and chat siblings when clearing workspace unread

A workspace holding a terminal pane and a structured chat tab built its
"anything still unread here?" inventory from the terminal tab list alone, so
acknowledging the visible terminal cleared the workspace's unread flag while
the chat's completion marker was still outstanding. The chat's unread was lost
with nothing left to relight it.

Structured chats now have their own attention-surface adapter, addressed by the
pane key the status producer already publishes — `<unifiedTabId>:<sessionLeaf>`
— with the unified tab id as its container id. Acknowledgement unions both
surface kinds' remainders, so either kind's hidden sibling holds the workspace
lit.

* fix(attention): rescan when focusing a split group
2026-09-18 12:37:28 -04:00
Jinwoo Hong 209d2d8df6 build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) (#21475)
* build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5)

The page bundled as one 8.16 MB script because every route was a static
import. The route manifest now defers each screen behind `import()`, the
build is esm with splitting on, and the document loads the entry as a
module. What the browser parses before the first route can paint drops
from 8.16 MB to 908 KiB; the whole page still weighs the same.

Two budgets hold it: the chunk count, which catches a split running away,
and the bytes the entry reaches by static import, which catches it
collapsing back. The second is the one that matters, and it is measured
from esbuild's metafile because only that says which import is static.

The RequireContext stays synchronous, since expo-router reads keys() to
build the route tree before anything renders. A lazy module cannot answer
`unstable_settings` or `ErrorBoundary`, which expo-router reads off the
namespace, so a test holds that no route in the subtree exports either.

The render check now waits for the route's own text: the entry's mount
signal lands while the route chunk is still being fetched.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read a route's synchronous exports from esbuild, not a regex

`export { x as ErrorBoundary }`, `export class ErrorBoundary` and a re-export all
reach the namespace without matching the declaration pattern the guard was
matching, so the lazy manifest dropped the boundary and the page painted blank.
A star re-export is now reported rather than read as clean.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say that the entry budget is not a per-route opt-out

Measured: statically importing one route already breaks the 3 MiB bound for 5 of
the 14. The hatch only works for a layout node, which is the only place
expo-router reads a synchronous export from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build(mobile): derive the chunk ceiling from the route count

64 was three routes of headroom over the 53 chunks 14 routes measure, so C2's
routes would have failed on a number measured before they existed. Four per
route plus 16 tracks the measured slope; the entry-bytes bound stays the real
budget.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the web entry's dead suspense boundary

expo-router wraps every screen in its own, so this one never fires; all nine
render checks stay green without it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a client-side navigation fetches the next route's chunk

Goes red with splitting off: the tasks screen paints out of the entry and no new
script is fetched.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name every bundle output by its bytes, not by esbuild's path hash

esbuild's [hash] is over the metafile's input keys, which are paths relative to
absWorkingDir, so a checkout at another depth or with node_modules as a symlink
named a byte-identical chunk differently and shipped a different buildId for one
commit. Outputs are now renamed leaves-first to the sha256 of their final bytes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fail the build on a route the lazy manifest would strip

The guard ran only in a test while the docstring said it failed the build. It
now runs in bundleMobileWebApp and names the route and the export.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build(mobile): derive the asset ceiling from the chunk ceiling and the images

A flat 128 stopped agreeing with the chunk ceiling at 18 routes, where the asset
count would have failed first and named the count instead of the split. Chunks
plus images plus the document keeps the chunk ceiling the one that trips.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): split the route-manifest tests out of the bundle builder's

The builder's test file passed 600 lines. The route manifest, the synthesized
RequireContext and the web entry are their own subject and move together.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give the export guard the builder's route-source loaders

Without .js as jsx the guard reported a React Native .js route carrying JSX as
"JSX syntax extension is not enabled" instead of reading its exports.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert the navigation fetches the tasks route's own chunk

"some new script arrived" passed on any fetch. The builder now names the chunk
each route lands in, read off the metafile, and the check asserts that exact
path arrived and was not already loaded.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): resolve a route's realpath before matching it to its chunk

esbuild writes metafile input keys after resolving symlinks, so every scratch
route tree under /var on macOS reached no output and failed the build.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fail the build when the asset ceiling outgrows the shell's map

The derived ceiling had no upper bound, and the native shells return null for a
manifest over their own 256 rather than truncating it. At 42 images the formula
crosses that at 50 routes, inside what Phase C adds, so the build would stay
green while the phone got nothing. The number is read from the contract through
esbuild, not restated here.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): cover the two hard stops in the content-addressed naming

Both throws only ran through a whole bundle before, where neither can be
provoked. A cycle and a route no output claims are now asserted directly; each
test goes red when its throw is removed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): exit the app-bundle build on one line, not a stack

The route-export guard fails this script by design, and a raw stack put the
route and the export name under twelve frames of node internals. Mirrors the
verifier's exit; the message is printed as thrown because every throw on this
path already names its source.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 11:58:42 -04:00
Brennan Benson a85e580e51 fix(orchestration): stop the sender-terminal refusal recommending another pane's handle (#21097)
* fix(orchestration): stop the sender-terminal refusal recommending another pane's handle

The structured-session guard told callers to pass `--from <terminal-handle>`, but the
explicit-flag branch returns before that guard runs — so following the advice succeeds,
against a handle that necessarily belongs to a different pane, and the next `check`
consumes that pane's unread mail.

Both refusals now say what is actually true: no handle names a structured chat session,
and a caller that does have one should pass its own.

Also pins ORCA_STRUCTURED_SESSION in the gate CLI test, which until now decided which
refusal it exercised from ambient environment.

* fix(orchestration): route the lifecycle-send refusal to the structured message

`orchestration send --type worker_done|heartbeat` refuses in the send handler before
`resolveOrchestrationTerminalHandle` runs, so the structured guard never saw the case a
structured session hits most: the canonical worker lifecycle report. That caller was still
told to pass `--from` with "your own terminal's handle" — which it does not have, so any
handle it picked would belong to another pane.

`throwNoActiveSenderTerminal` now derives which refusal fits instead of each call site
deciding: marker set AND no handle means no identity exists, so the structured refusal
applies. A stale `ORCA_TERMINAL_HANDLE` is deliberately excluded — that caller does have an
identity, it just went stale, and keeps the advice to re-run under a live one.

Also corrects the guidance itself (`--agent` is a `worktree create` flag; `terminal create`
has no such flag), aligns the SSH fallback wording with its local twin, and pins
ORCA_STRUCTURED_SESSION in the send tests, which until now decided which refusal they
exercised from ambient environment.
2026-09-18 08:15:12 -07:00
Brennan Benson 66e0847398 fix(agent-status): stop an auto-reviewed Codex approval reading as "Needs You" (#21389)
* fix(agent-status): stop an auto-reviewed Codex approval reading as "Needs You"

Codex runs its PermissionRequest hook as decider #1, ahead of both its own
review agent and the user, so the event means "a decision is being made", not
"a human is blocked". Under the "Approve for me" posture the review agent
resolves it seconds later, so every gated tool call drove the pane from Working
to Needs You and back, plus a desktop notification each time.

The execution host now reads the turn's approvals_reviewer off the rollout it
already tails for subagent reconciliation, and keeps a reviewer-owned approval
as working. Positive evidence only: an absent field, an older rollout, or an
unreadable file all still raise the wait, so this can never hide a real prompt.

Splits the incremental rollout JSONL cursor out of the subagent transcript
module, which the new reader pushed over the file-length cap.

* fix(agent-status): avoid stale Codex approval ownership

* fix(agent-status): reconcile Codex child approval ownership

* perf(agent-status): avoid reads for Codex child activity

* fix(agent-status): scope Codex reviewer ownership by transcript
2026-09-18 11:04:05 -04:00
Jinwoo Hong ddbb194585 feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4) (#21467)
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3)

One page document's end of the bridge: page frames in through the C0.1
reader, one RpcClient behind it, host frames out. Requests forward with the
arity the page used and answer with the verbatim RpcResponse, chunked when it
is over the frame cap; a rejection crosses as the five-field capture instead.
Subscriptions carry a seq and an unacked window, and end with `overflow`
rather than dropping frames a reader cannot see are missing.

The fence is structural: the protocol names no host, so the client is
whichever this host was built with, and the in-flight caps the page is told
about in `init` are enforced here rather than trusted from there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3)

The channel opens on the session B4 put on screen and closes with it. The
session id is B4's: nothing new is minted, and a remount is a new one, which
is what makes a dead page's frames fail the native origin check.

Both halves are stamped with the session they belong to, because React swaps
refs during the commit and runs the retiring effect's cleanup after it — a
host disposing on a remount would otherwise post its teardown into the page
that replaced it. `bridgeEnabled` is derived from the session step alone,
since the native side treats a prop change as a reload.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the bridge fence holds for traffic, not just for answers

A mutation that dropped the post-teardown guard in `receive` survived: the
teardown case only fed a frame whose answer the outbound guard already
swallowed, so nothing observed that a dead page could still reach a live
client. Both teardown paths now feed a request, a subscribe and a notify,
and assert the client saw none of them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the hook's frames through the page's own reader

`JSON.parse` returns `any`, and taming it with an assertion is a cast the
gate refuses and a check nobody gets. Reading each posted frame through
`readBridgeHostMessage` types it and proves the same thing the host's own
suite does: a frame the page would refuse is a frame that never arrives.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): list the bridge host as a raw request port owner

The boundary ratchet reads a `.sendRequest` access as a call site, and the
host has three: one per arity the page can use. It is not a call site. It
picks no method, reads no reply and decides no acceptance — the page names
the method and runs the typed operation over the client this carries, which
is what the C0 design put page-side so `runRpcOperation` stays unchanged
there. That makes it an owner, beside the socket and relay senders, not a
migration backlog entry.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4)

Every member of the native contract, carried over the C0.1 envelope so the
screens above it cannot tell a bridge from a socket: requests keep the arity the
caller used, a host RpcFailure resolves as data while a rejection is rebuilt with
its class and its delivery-unknown mark, subscriptions stream with periodic acks,
and the synchronous getters read a cache primed by init rather than answering
before they know.

A state whose generation went backwards is refused and re-asked for, because a
shell rebuilt under the page makes what the page holds the newer of the two.
close settles what the page owns and never touches the shell's client, which the
native screens and the host catalog still share.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): run the page client against the shell host over an in-memory port pair

One FIFO per direction and delivery on a microtask, which is what C0.5's golden
replay needs: a subscribe that overtook a sendRequest would move the recorder's
shared ordinal, and anything stronger than a microtask moves a virtual
millisecond. Every member round-trips through the real host over a fake client;
the frame-level suite covers what no pair can reach, including the handshake
backoff, refusals and the binary lane C6 will fill.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): list the page bridge client as a raw request port owner

Both ends of the bridge hold the port as a transport: one forwards raw requests
and the other offers them, and neither picks a method or reads a reply.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the assembler discard no abandoned request can reach

A request is only abandoned when its frame never left the page, so the shell was
never told the id and no part can have arrived under it. Says what actually keeps
an omitted param omitted while it is here: JSON drops an undefined value, so the
spread states the intent rather than producing the result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): close the three gaps a mutation sweep found in the page client

A settled id has to give its assembler slot back, or 64 replies that were cut
short before an error leave the page unable to read the next chunked one. Close
says goodbye once rather than cancelling each stream first. And the read guard is
only observable through a port that ignores its own unsubscribe, which is what
the harness can now be.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove a stream that overflows inside subscribe is unsubscribed

A client that emits synchronously from `subscribe` can retire a stream before
its unsubscribe exists to be stored. The identity check that calls it instead
had no test; deleting it left the suite green while the client's stream leaked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hand the bridge host over in the commit, not after it

A client swap that keeps the session id leaves the handler's own fence inert:
until the passive effect ran, a native frame reached the retiring host and the
client it closed over. A layout effect swaps both inside the commit.

Teardown on unmount now runs while the view is still attached, so a pending
request is answered delivery-unknown instead of being dropped on the floor.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold a refused page frame to one warning per page

A page that sends one bad frame usually sends many, and a line each buries the
first — the one that says why. Same bound the host already keeps on a failing
post, applied per kind and reset when a new page gets a new host.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound the page's terminal viewport at the bridge contract

A viewport crossing the bridge is written into the cached subscribe params of
every stream naming that terminal, including the native terminal screen's, and
the desktop refuses cols over 1000 or rows over 500 when those streams
resubscribe. Unbounded, one page could kill streams it never opened; the frame
is refused instead, and the bound is pinned to the desktop's own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the page's close from latching the bridge host shut

One view carries every document the shell loads, so the page that says `close`
is not the last one. A latched host dropped the next document's `ready` in
silence, and a page that re-sends `ready` on a backoff would retry forever with
nothing posted and nothing logged. Close now cancels what the page owned and
leaves the host live; only dispose shuts it, and a frame arriving after that is
diagnosed rather than dropped.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a throwing client or post inside the bridge host

The `state` frame is sent from inside the client's own state-change fan-out and
a notify runs on the native event handler that delivered the page's frame, so a
synchronous throw from either escapes into a loop the bridge does not own and
takes unrelated listeners with it. Both are fenced and reported once.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove an ack releases the stream's unacked bytes

The frame window reopens on ack through the splice, so deleting the byte
release left every existing test green while a long-lived stream of large
frames would end with overflow on its first frame after an ack.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pass the commit-window harness its children as a prop

`createElement`'s variadic children do not satisfy a props type that declares
`children`, so the file dropped out of the tests typecheck ratchet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): render the harness from the commit-window wrapper, not as children

A props type that declares `children` is what `createElement`'s variadic form
does not satisfy, and passing it as a prop instead trips the react rule. The
wrapper renders the harness itself, which is the parent position the layout
effect ordering needs anyway.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pin the desktop viewport bound by reading it, not importing it

Mobile may not pull an rpc-contract *value* into its bundle, and the boundary
test that enforces that scans this test file too. The pin reads the schema's
own source instead, so drift in either bound still fails loudly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): settle a refused subscribe as the stream it was

The shell answers a refused `subscribe` with `error` on the stream's id.
Routing that to the pending requests dropped it, because no request is
open under that id: the page heard nothing and kept the slot forever.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): report a reply or an error the page has no id for

Silently dropped before. Nothing recovers it in place, but a frame the
page cannot place means the two ledgers disagree, which is worth a line.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say disconnected on close instead of going silent

Every native client publishes the transition and keeps answering its last
snapshot; the screens read both. The page's client cleared the cache
instead, so a closing page left its listeners on a dot that never moved
and every getter throwing underneath it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let a closed page client go inert, not throw

An unmounting screen still calls, and nothing on a teardown path catches.
Subscribe hands back a no-op dispose and the notifies do nothing, as the
native client's do, and a request rejects rather than throwing past the
caller's catch. A call before init still throws: that one is a bug.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): lift the init handshake out of the page client

The backoff that asks the shell for a session is its own concern, and the
client had grown past the file's line budget holding it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the cancel a page owes for a stream already ended

A screen unmounts on its own schedule, routinely after the shell gave up
on the stream. Only the double-dispose order was covered.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state what the page client does after close

The doc gave the pre-init rule and stopped; the after-close rule is the
opposite one, and subscription failures have no channel but a diagnostic.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): read the shell's page channel as a client transport

The document-start installer leaves `postMessage` and one `onmessage`
slot, the intersection of what the two platforms inject. A page opened
outside the shell has no global at all, so reading it answers null rather
than throwing: the bundle still has to open in a browser.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the page its bridge client instead of a placeholder

The web provider now builds BridgeRpcClient over the shell channel and
mounts nothing until `init` lands: every member throws before a session,
and a screen that rendered first would record its first frame against a
client that has none. Outside the shell there is no session coming, so
the placeholder stays and the route tree mounts at once, which is what
the Route A render check exercises.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): declare the page provider test's probe instead of casting it

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve the bridge to one document at a time

A page's `close` now ends that document's turn: until the next `ready`
claims the view, every other frame is dropped and diagnosed instead of
reaching the client, and nothing is posted. Without the fence a straggler
from the closed document was still forwarded, and a `state` frame from the
still-running client landed in the replacement document before its `init`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the request cap against the calls, not the page's ledger

`sendRequest` has no cancel, so a request the page cancelled or closed out
keeps running on the desktop until it answers. The cap now counts those
calls until each settles; counting the pending map let a page interleaving
`close` with batches hold many more than the cap `init` advertises.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ack ratio to the shell's window, not a copy of it

The ack interval test held 256 and 4 MiB as literals, so narrowing the
shell's window would have left the page acking too late with the test still
green. The comment naming the test that pins the ratio pointed at the wrong
file.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give back the slot of a subscribe that never left the page

A post that threw left the stream in the page's ledger with nothing open on
the shell's side, so 32 of them exhausted the subscription budget for the
life of the document. The slot goes back and the listener hears a terminal
error result, which is what the native client does with the same failure.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): end a page stream through its listener, not only the log

A stream the shell ends or fails now reaches its listener as a terminal
error result, the way the native client's emitError does. A consumer reads
that result: host-worktree-refresh clears the flag that says the event
stream is live, and without it the worktree list stops updating for the life
of the document. A dispose the page asked for stays silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): settle the old shell's work before adopting a new session

A second `init` naming a different sessionId is a rebuilt host with empty
tables: every pending request and every open stream the page still held
belonged to the shell that is gone. They now settle delivery-unknown and end
through their listeners before the new session is adopted. A second `init`
for the same session is what a re-asked `ready` earns, and keeps everything.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): take the page's streams out of the ledger before failing them

A listener that resubscribes while the old shell's streams are being ended
is opening one against the shell that is arriving; draining the map first is
what keeps this loop from tearing that one down too. Fixes the lint the
previous commit left behind.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say how long a reply assembler's refusal actually lives

The tombstone is not kept forever: the request ledger discards the id as it
settles the caller, so it normally outlives only the rest of the reply that
raised it. The bounded map is there for the ids nothing settles.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the ceiling the ready backoff stops widening at

An unclamped backoff reads the same for the first minute and then leaves a
page asking once an hour into a shell that is still booting behind it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say why the document fence carries no epoch

Page frames reach the shell through one native listener per platform, so a
straggler from the closed document lands before the next document's `ready`
and the flag alone catches it. An echoed epoch would be a wire change for
nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 10:51:38 -04:00
Jinwoo Hong f2be6299c8 feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) (#21459)
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3)

One page document's end of the bridge: page frames in through the C0.1
reader, one RpcClient behind it, host frames out. Requests forward with the
arity the page used and answer with the verbatim RpcResponse, chunked when it
is over the frame cap; a rejection crosses as the five-field capture instead.
Subscriptions carry a seq and an unacked window, and end with `overflow`
rather than dropping frames a reader cannot see are missing.

The fence is structural: the protocol names no host, so the client is
whichever this host was built with, and the in-flight caps the page is told
about in `init` are enforced here rather than trusted from there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3)

The channel opens on the session B4 put on screen and closes with it. The
session id is B4's: nothing new is minted, and a remount is a new one, which
is what makes a dead page's frames fail the native origin check.

Both halves are stamped with the session they belong to, because React swaps
refs during the commit and runs the retiring effect's cleanup after it — a
host disposing on a remount would otherwise post its teardown into the page
that replaced it. `bridgeEnabled` is derived from the session step alone,
since the native side treats a prop change as a reload.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the bridge fence holds for traffic, not just for answers

A mutation that dropped the post-teardown guard in `receive` survived: the
teardown case only fed a frame whose answer the outbound guard already
swallowed, so nothing observed that a dead page could still reach a live
client. Both teardown paths now feed a request, a subscribe and a notify,
and assert the client saw none of them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the hook's frames through the page's own reader

`JSON.parse` returns `any`, and taming it with an assertion is a cast the
gate refuses and a check nobody gets. Reading each posted frame through
`readBridgeHostMessage` types it and proves the same thing the host's own
suite does: a frame the page would refuse is a frame that never arrives.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): list the bridge host as a raw request port owner

The boundary ratchet reads a `.sendRequest` access as a call site, and the
host has three: one per arity the page can use. It is not a call site. It
picks no method, reads no reply and decides no acceptance — the page names
the method and runs the typed operation over the client this carries, which
is what the C0 design put page-side so `runRpcOperation` stays unchanged
there. That makes it an owner, beside the socket and relay senders, not a
migration backlog entry.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove a stream that overflows inside subscribe is unsubscribed

A client that emits synchronously from `subscribe` can retire a stream before
its unsubscribe exists to be stored. The identity check that calls it instead
had no test; deleting it left the suite green while the client's stream leaked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hand the bridge host over in the commit, not after it

A client swap that keeps the session id leaves the handler's own fence inert:
until the passive effect ran, a native frame reached the retiring host and the
client it closed over. A layout effect swaps both inside the commit.

Teardown on unmount now runs while the view is still attached, so a pending
request is answered delivery-unknown instead of being dropped on the floor.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold a refused page frame to one warning per page

A page that sends one bad frame usually sends many, and a line each buries the
first — the one that says why. Same bound the host already keeps on a failing
post, applied per kind and reset when a new page gets a new host.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound the page's terminal viewport at the bridge contract

A viewport crossing the bridge is written into the cached subscribe params of
every stream naming that terminal, including the native terminal screen's, and
the desktop refuses cols over 1000 or rows over 500 when those streams
resubscribe. Unbounded, one page could kill streams it never opened; the frame
is refused instead, and the bound is pinned to the desktop's own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the page's close from latching the bridge host shut

One view carries every document the shell loads, so the page that says `close`
is not the last one. A latched host dropped the next document's `ready` in
silence, and a page that re-sends `ready` on a backoff would retry forever with
nothing posted and nothing logged. Close now cancels what the page owned and
leaves the host live; only dispose shuts it, and a frame arriving after that is
diagnosed rather than dropped.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a throwing client or post inside the bridge host

The `state` frame is sent from inside the client's own state-change fan-out and
a notify runs on the native event handler that delivered the page's frame, so a
synchronous throw from either escapes into a loop the bridge does not own and
takes unrelated listeners with it. Both are fenced and reported once.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove an ack releases the stream's unacked bytes

The frame window reopens on ack through the splice, so deleting the byte
release left every existing test green while a long-lived stream of large
frames would end with overflow on its first frame after an ack.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pass the commit-window harness its children as a prop

`createElement`'s variadic children do not satisfy a props type that declares
`children`, so the file dropped out of the tests typecheck ratchet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): render the harness from the commit-window wrapper, not as children

A props type that declares `children` is what `createElement`'s variadic form
does not satisfy, and passing it as a prop instead trips the react rule. The
wrapper renders the harness itself, which is the parent position the layout
effect ordering needs anyway.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pin the desktop viewport bound by reading it, not importing it

Mobile may not pull an rpc-contract *value* into its bundle, and the boundary
test that enforces that scans this test file too. The pin reads the schema's
own source instead, so drift in either bound still fails loudly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve the bridge to one document at a time

A page's `close` now ends that document's turn: until the next `ready`
claims the view, every other frame is dropped and diagnosed instead of
reaching the client, and nothing is posted. Without the fence a straggler
from the closed document was still forwarded, and a `state` frame from the
still-running client landed in the replacement document before its `init`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the request cap against the calls, not the page's ledger

`sendRequest` has no cancel, so a request the page cancelled or closed out
keeps running on the desktop until it answers. The cap now counts those
calls until each settles; counting the pending map let a page interleaving
`close` with batches hold many more than the cap `init` advertises.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 10:46:47 -04:00
Jinwoo Hong 381a3da46f feat(build): Route A, the phone's host routes bundled for the web, dark (OTA phase C, C0.7) (#21449)
* refactor(mobile-web): share the bundle manifest assembly with a second builder

Manifest assembly and the on-disk write move to writeMobileWebBundleTree, and
the helpers the Phase C app builder needs become exports. No behaviour change
to the shipped bootstrap bundle.

The CRLF guard grows two exemptions it needs once it is pointed at mobile/src:
the image and font extensions .gitattributes already pins -text, and the
gitignored webview engine modules the postinstall writes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): web entry for the host route tree, and its two transport siblings

The entry mounts app/h on react-native-web through expo-router's own ExpoRoot.
It lives inside mobile/ so one React resolves, and supplies RpcClientProvider
itself: the route tree starts below the native root layout that owns it.

route-manifest.ts is a real typed module whose body the builder replaces --
esbuild has no require.context. A virtual specifier would need an ambient
declaration and would leave the entry unchecked.

Two .web.* siblings, both listed with a reason in web-overrides.json: the
transport substitution point (a placeholder client until C0.4 lands
BridgeRpcClient) and the device token store, whose native path imports
expo-secure-store, which is {} on web.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(build): build:mobile-web:app, the phone's host routes bundled for the web

Same builder shape as the Phase A bootstrap into a separate out/mobile-web-app,
with the same manifest and the same two-scratch-build determinism check. Dark:
build:mobile-web, packaging and the A2 census are untouched, and C1 is what
flips build:release.

Six shims, each a named Metro or RN Web gap. Images are emitted as same-origin
hashed assets rather than data: URLs, because the shell's CSP sets img-src
'self'; the render check under that exact header is what found it. The script is
referenced root-absolute for the same reason a <base> tag cannot be used: the
document is served at every route depth and base-uri is 'none'.

The budget sits below the contract's per-asset ceiling so growth trips a build
rather than a refused asset on a phone. esbuild splitting does not lower it:
one entry with only static imports emits one chunk (measured).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let React Native Web paint under the shell CSP

RN Web 0.21.2 injects its stylesheet at runtime with no nonce support, so
style-src 'self' blocks every rule and the page renders unstyled. Measured, not
predicted: the render check serves the document under this exact header and
reported the violation.

'unsafe-inline' is granted to style-src and nothing else. script-src 'self'
holds, which is the directive that decides whether page code can arrive any way
other than as a fetched same-origin script. The test now pins that scoping
rather than rejecting the token everywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci: prove the Route A app bundle on every PR

A dedicated job, for the same reason the browser provider has one: it needs
mobile/node_modules and a real browser, and the sharded test matrix would pay
for both on every shard. It builds the bundle, verifies it, and runs the
builder, override-census and render suites. It ships nothing.

The mobile_web_app signal is lifted out of should_run the way static_analysis
is. A mobile-only diff is desktop-irrelevant and skips every gated job, and
that is exactly the diff that changes the page this job builds.

Also the C0.6 review follow-up: mobile/package.json and mobile/pnpm-lock.yaml
join the installer cache keys in the two workflows that build an installer off
a hashFiles key, since beforePack requires out/mobile-web and a mobile-only
change must miss those caches rather than reuse a stale build.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): pin the shipped builder against the app builder's own module name

The assertion named a specifier that no longer exists, so it held vacuously.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert the RN Web style-src grant in the Swift checks

The Swift twin of the Kotlin CSP test still required style-src 'self' and
no unsafe-inline anywhere, so it trapped on the approved grant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): make the Route A render check name what each route paints

The check asserted only "some html, no errors", which expo-router's Unmatched
screen satisfies: pointing HOST_ROUTE at /zzz/not-a-real-prefix stayed green.
Each route now asserts content only its own component produces, and the
unmatched case asserts the screen positively so the negatives discriminate.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): read the shell CSP past the comments that quote directives

Both constants document themselves with // comments containing quoted
directive text, which the quoted-string scan picked up as directives. One
parser now drops comment lines, and iOS and Android go through it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(build): honour a .web.* route sibling in the app bundle

Routes were imported by absolute path with the extension, so esbuild's
resolveExtensions never applied and a .web.tsx under app/ was dead code the
census still accepted. The manifest now carries a key and a module: the key
stays the native filename so the URL does not move, and the module is the web
sibling when one exists.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): tie each named shim to the esbuild option that implements it

The shim list was asserted against a literal copy of itself, which passes
however the build is configured. Each entry now carries an appliesTo that
reads its own option, checked against the real options object.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(build): line up the CRLF exemptions, the budget comment, and the job scope

The builder loads .gif as a file but neither .gitattributes nor the CRLF scan
exempted it, so the blanket eol=lf pin would have rewritten one. A test now
keeps the two lists in step. The Phase C byte budget's comment sat on the
asset count, and a root package.json edit could change build:mobile-web:app
without running the job that proves it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(build): satisfy the index-check lint rule in the CSP parser

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci: key the installer caches on the mobile page trees too

beforePack builds the mobile web bundle into the installer. Today those bytes
are Phase A's, which src/** already covers, but once C1 flips the entry to
mobile/app a page-only change would hit a cache holding a stale installer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): skip the bundling tests where mobile dependencies are absent

The sharded `test` job collects config/scripts/**/*.test.mjs and installs no
mobile dependencies, so the two new suites failed there on "Could not resolve
react-native-web". They now skip themselves with a message naming the job that
runs them, and that job sets ORCA_MOBILE_WEB_APP_DEPS_REQUIRED so a missing
install fails it instead of skipping everything it exists to prove.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(build): scan mobile/packages in the .web.* census

The census claimed the app entry never resolves into packages/, but the
dictation hook imports @orca/expo-two-way-audio and the built script carries
ExpoTwoWayAudioModule.web.ts. That file is now listed with its reason, and
planting a .web.* in each scanned tree proves the scan is not passing because
a tree happens to be empty.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): assert the route exclusions against a tree that has them

mobile/app holds no test, spec or +api file, so the exclusion rule was
asserted against a tree it could not fire on. A scratch tree plants one of
each; dropping the rule now fails this test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): 404 unknown file paths in the render check's page server

The server answered every path with the document, so pointing publicPath at
/wrong-prefix still rendered three green routes: the script is fetched from
the one prefix that is served. A path naming a file now has to come out of the
bundle, which is what the shell's manifest map does.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): cover the app bundle verifier's own checks

The verifier had no test. One doctors the buildId, which the packaged assert
catches; the other rewrites the tree so every digest still agrees and only the
two fresh builds can tell, which is what a stale out/ looks like. Deleting
either check now fails a test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(build): tidy the app bundle comments and the job's path prefixes

Drops an export nothing read, merges two comments that had drifted apart from
the constant they describe, and corrects the claim that the job runs on every
PR when it is path-gated. package.json leaves the prefix list because
GLOBAL_FORCE_FILES already forces every job on it; mobile/packages/ joins it,
since the page resolves a .web.ts out of there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(build): merge the duplicate node:fs/promises import in the census

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): redirect the hybrid shell route on the web page

app/h/[hostId]/web.tsx reaches OrcaMobileWebShellView, whose module calls
requireNativeViewManager at import. In a browser that throws before React
mounts, and the route manifest imports every route statically, so one native
route left the whole page blank at every URL.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): fail the render check with the error that stopped the mount

The check waited on "#root has children" with Playwright's animation-frame
polling, so a route module that threw at import read as a bare 30s timeout
naming nothing. It now waits on a mount attribute the entry sets after the
router commits, polls on a timer, and races the wait against the first
uncaught error so the failure carries it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): answer the favicon the render browser asks for

CI resolves the runner's Google Chrome, which requests /favicon.ico; the
bundled headless shell does not. The bundle carries no icon, so the server
answers 204 rather than turning a browser habit into a console error the
render assertions read as a page fault.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): settle the render check's uncaught-error race without rejecting

The entry throws during goto, before anything awaits the race, so a rejected
promise surfaced as an unhandled rejection beside the real failure. The same
signal now resolves with the error.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): list the page transport in the raw request port inventory

The placeholder client implements the port, so the boundary test counts it as
an unlisted file. It belongs under OWNERS until C0.4's BridgeRpcClient
replaces it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 09:50:37 -04:00
github-actions[bot] a98314e8bb Update README downloads badge 2026-09-18 12:35:55 +00:00
Jinwoo Hong 47d107cf2e feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435)
* refactor(mobile): say whether a host status was readable, and carry its protocol window

`useHostStatusGates` settled the same closed gates for a host that answered
`status.get` with no capabilities and for one whose status nobody could read:
both paths produced an empty capability list and an `ok` verdict. A caller that
walls on a missing capability cannot tell those apart, and the mobile web
bundle's wall is terminal, so it must never fire for the second.

`statusReadable` distinguishes them. `hostProtocolWindow` exposes the two
protocol numbers the hook already read for `evaluateCompat`, as the reply's own
fields, so the bundle wall can evaluate its own window without a second
`status.get`. Both are additive; no existing consumer changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): add the hybrid shell flag and the generation path both loaders demand

`orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same
shape as the terminal autocomplete flag.

`generationDirectoryPath` converts the store's `file://` uri to the absolute
path the native shell view requires: both `MobileWebShellGeneration.load`
implementations refuse anything without a leading slash, and `expo-file-system`
only ever hands out uris.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the hybrid shell session as a pure reducer

Every decision the route makes, as `(session, event) -> (state, effects)`: the
capability wall, the lazy sweep and cache read, the manifest check, the cached
build-id hit that skips paging, the offline open with no compat check, and the
three recovery rules the native shell view's contract states.

Pure, so the rules are table tests rather than a simulator run. Two latches sit
beside the state because both outlive it: `retriedOnce` spans the delete and
refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced
by a `ready` under a new session id.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4)

Wires the four Phase B and A pieces together and adds no decision of its own.
`h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native
`desktop-too-old` wall still applies above it. With the flag off — every store
build, since the only writer is a `__DEV__` Troubleshoot toggle — the route
redirects to `h/[hostId]` and the screen is never constructed, so nothing is
fetched, written or swept.

The runner owns only the impure edges and checks an epoch before every dispatch,
so an unmount, a host change or a retry abandons work in flight and aborts a
download that would otherwise hold four of the host's read slots. The native view
is keyed on the session id, which is what makes the reducer's remount a rebuilt
WebView with every fence reinstalled.

A census test pins who touches the flag: the route reads it, the developer row
reads and writes it, and the key itself lives in one module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): type the hoisted test doubles instead of asserting them

The changed-code casting gate refuses `as` in new code, and these three were
only widening an empty literal. An annotated `vi.hoisted` factory does the same
job under a check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress

Found on a simulator with the paired desktop stopped: the client never settles
on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting`
-> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping
`reconnecting` to "still connecting" left a phone holding a verified cached
generation on `checking` forever instead of opening it, which is the one case
the offline rule exists for.

`connecting` and `handshaking` are the first dial and still wait; everything
else is unreachable. The mapping moves next to the reducer it feeds, because it
is a decision and the runner is supposed to hold none.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): build the reachability stub instead of asserting it

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): move the shell session vocabulary into its own module

Pure move, no behaviour: the states, events, effects and gates the reducer
and its runner share now sit beside the reducer rather than inside it, so
the transition rules have room to grow under the file's line budget.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop a shell effect result whose flow has been superseded

Every restart of the flow bumps a number the effects of that run are stamped
with, and a result echoes it back: a manifest read still in flight when the
socket drops used to reject after the offline path had already opened the
cached generation, replacing a displayed workspace with a download failure,
and a status refetch arriving mid-check used to run the cache read and the
download twice. The gates restart no longer clears the remount latch either;
only the retry button does, so a reconnect cannot grant a second remount.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census the flag across modules, not just src and app

The native view tree was outside the scan, so a reader added there would
have passed an assertion that reads as exhaustive. Proven by adding one to
the shell view module: the census fails.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let only the state that mounted the view hear the view

A native batch reports two failures in a row, and the reducer applied both:
document-load-failed started the delete-and-refetch, render-process-gone
then made it terminal without a new flow, and the cache read the recovery
had already asked for dragged the session back to checking behind a failure
screen. A report arriving outside `ready` is from a view that is no longer
on screen, so it changes nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): say a host status could not be read instead of spinning on it

A transient status.get failure settles the gate unreadable and nothing probes
it again, so the route sat on "Checking host" for as long as anyone left it
there and Try again re-read the same settled answer. It now says what
happened and offers no retry, and a status that does become readable picks
the flow back up on its own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): restart the flow on the verdict that changed, not on every gates object

A reconnect cycle rebuilds the gates several times a second with the same
answer in them, and each one re-swept the staging tree and flipped an offline
screen to a spinner and back. Only a changed verdict restarts now, which is
also why the gates effect has to depend on the host id: two hosts whose gates
read identically would otherwise leave the second session in `checking`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): cover what only the shell runner can get wrong

Three cancellations had no test: the epoch that stops a result reaching a
session that is gone, the unmount cleanup that aborts the download, and the
retry that does both before starting over. Each is now red under its own
mutant. The download also re-checks the abort before it writes, since an
abort landing between the fetch's last read and the commit would otherwise
still put a generation on disk for a screen nobody is on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write the shell runner's refs after the commit, not during render

React can replay or discard a render, so a handle written during one can run
effects for a session that never existed. The client and the host cache key stop
being refs at all; the effect handle is committed in an effect above every
effect that dispatches.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the hybrid shell flag unreadable outside a development build

Development and release share a bundle id, and the iOS data container survives
an install-over, so a flag a developer toggled on would follow the store build
in and mount the shell on a deep link. The release read never reaches storage.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drive the route's flag read as each build kind reads it

The route test exercises the real preference read, so it has to say which build
it is. A store build whose container kept a development toggle redirects.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let a cache read that lands mid-dial wait for the compat check

A connection still being made is not a host that cannot be reached. Opening the
cached generation there skips the compat check the landing connection is what
makes answerable, so only `unreachable` takes the offline path now.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): move the developer toggle only after its write lands

The route reads the flag back from storage, so a switch that moved on the tap
let the open button race the value that was being persisted. The switch and the
button both stay put until the write settles, and a failed write keeps the
previous position.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): say which build kind a test runs as without asserting on globalThis

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): undo a staged generation the abort reached before the commit

The commit is the write staging cannot take back: it renames into the active
slot and moves the host index. An abort landing while the bytes were being
staged now removes the staged tree instead of activating it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): open the cached workspace when the link, not the bundle, cut a read short

An RPC rejection can reach the reducer before the reachability change does, so
the offline gate never fires and a phone holding a valid generation reads that
the workspace could not be downloaded. A read that failed on the link now opens
what is on disk, the same path offline takes; a verdict about the bundle, from
the host or from the bytes, still fails.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send a hybrid shell recovery through the same gate a start takes

A view failure deleted the host cache and went straight back to the manifest
check on whatever gates the ready session happened to be holding. Gates that
arrive while a generation is on screen are stored without restarting, so after a
reconnect whose status probe failed a ready session carried statusReadable false
and an empty capability list, and the recovery's manifest check walled the host
as bundle-unavailable: terminal, no retry, about a host that never answered.

The gate is now one verdict both entries read, and recovery passes its delete
through it, so an unreadable status lands on the status-unreadable message that
re-arms when a readable gate arrives, and only a readable refusal still walls.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 08:22:25 -04:00
Jinwoo Hong 3aefee4a13 feat(mobile): native page-shell bridge in orca-mobile-web-shell (OTA phase C, C0.2) (#21434)
* feat(mobile): native page↔shell bridge in orca-mobile-web-shell (OTA phase C, C0.2)

Adds one prop, one event and one view function to the shell view, off unless
asked for: with `bridgeEnabled` false nothing is registered on either platform,
so Phase B's behaviour is byte-identical.

iOS accepts a `WKScriptMessageHandler` message only from our own WebView, the
main frame, the `orca-mobile-web` scheme and the session we loaded under, and
replies through `callAsyncJavaScript` with the payload bound as a real JS value.
Android registers a `WebMessageListener` gated on a `WEB_MESSAGE_LISTENER`
feature query (Chromium 88; unsupported is `isolation-unavailable`, and only
when the bridge was asked for) and replies through the reply proxy.

Simulator-measured before any acceptance logic was written: WKFrameInfo's
securityOrigin does populate for the custom scheme, but WebKit ASCII-lowercases
the host, so `orca-mobile-web://sess-01JN_aZ9/` reports `sess-01jn_az9`. Exact
equality would refuse every message from a mixed-case session id. Folding is
ASCII-only rather than caseInsensitiveCompare, because U+212A KELVIN SIGN folds
to `k` under Unicode and would match a host nobody minted.

The 640 KiB cap is measured on the raw UTF-8 string. Inbound it is a silent,
counted refusal; outbound `postBridgeMessage` throws, because its only caller is
the host and a dropped reply is a request that never settles.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pick the completion-handler callAsyncJavaScript overload

The trailing closure resolved to the `async` overload, which the compiler read
as an extra trailing closure. The label is `in contentWorld:`, and naming the
completion handler is what selects the synchronous one. Restates the two
exception classes' inherited Sendable conformance, which Swift 6 warns on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fold the request host ASCII-only, shared with the bridge

`resolveRequestPath` compared the request host with `caseInsensitiveCompare`,
which folds U+212A KELVIN SIGN to `k`, so a host nobody minted could match a
session id containing `k` and be served every asset. Both predicates now use
one `MobileWebShellOrigin.asciiLowercased`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): converge the shell load guard on applied props, not install success

The re-entry guard compared `bridgeEnabled` with `bridgeInstalled`, which is
written only where the install succeeds. With the prop true, every early return
— malformed session id, unreadable generation, a WebView with no
WEB_MESSAGE_LISTENER — left the two unequal, so the next prop commit re-entered,
reset the state machine and re-emitted loading then failed, forever.

Both platforms now record the prop triple and compare it field by field in one
pure `MobileWebShellAppliedProps.matches`, checked by swiftc and JUnit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): settle postBridgeMessage on delivery and bind it to the frame that spoke

postBridgeMessage resolved whatever happened: the completion handler was nil,
and `bridgeInstalled` stayed true after the renderer died and after a failed
prop update, so the host's request never settled. It also posted with `in: nil`,
which means the current main frame, while page to native binds to the applied
session.

Both ends now use the frame the last accepted message came from, checked
against the applied session id with the same ASCII fold, and the promise is
rejected when there is nowhere to post or when WebKit reports the delivery
failed. Android drops its reply proxy on the same three events for parity.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the bridge delivery script throw when the page has no bridge

`if (bridge) { bridge.__deliver(m) }` made a page the installer never ran in
indistinguishable from a delivered message: the script completed, so
callAsyncJavaScript succeeded, so the host's promise resolved on a message
nobody received. Unguarded, the missing global throws and the promise rejects.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the applied-props record to the fields it compares

Nothing failed if a fourth prop joined the record and no comparison mentioned
it — the prop would simply never reload. Both suites now assert the record's
stored fields by name, so adding one without deciding whether it re-enters is
red rather than silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): import assertEquals for the applied-props field pin

Belongs with the previous commit, which left the import behind; no amend.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse and unbind the document a prop update replaced

Two ways the previous document kept speaking for the load that replaced it. On
Android a failed prop update nulled `served` and the reply proxy but left the
web message listener installed, so a page still alive after `stopLoading` posted
through a listener bound to the origin this mount had stopped serving, and
re-armed the proxy doing it. Every disable path now goes through one removal.

On both platforms that document is same-origin whenever only the directory or
the bridge prop changed, so it passed acceptance between `stopLoading` and the
next commit and emitted after the host was told `loading`. Acceptance is now
armed at navigation commit — `didCommit` on iOS, `onPageStarted` on Android —
and disarmed by a new prop triple, a failure, and a renderer that died. The
state lives in the load-state machine and the arming clause is a field of the
pure accept predicate, so both are checked by swiftc and JUnit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the bridge post target only for the document that armed it

`WKFrameInfo` outlives the frame it describes, so the held target has to be
cleared at the commit that re-opens arming as well as at the provisional start,
and a post in flight between the two has no document to go to.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): publish the Android bridge state written off the main thread

`reportDocumentFailure` runs from `shouldInterceptRequest`, so the reply proxy
it drops and the commit flag it clears are written off the UI thread that reads
them. Same reason `documentFailed` and `served` already carry it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what a resolved postBridgeMessage does not prove

Android's reply proxy is void with no acknowledgement, so resolve there means
enqueued. The shared handle promised delivery, which is only ever an iOS answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 07:38:47 -04:00
Jinwoo Hong 8fc81182ab feat(mobile): envelope contract for the web shell bridge (OTA phase C, C0.1) (#21432)
* feat(mobile): bound a web-shell bridge frame at one enforcement point

The page and the shell exchange frames over a native channel that will happily
carry whatever either side hands it. `parseBridgeMessage` is the only place the
byte, depth and node caps are checked, and the byte cap is checked against the
raw string so it protects `JSON.parse` rather than trusting it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): carry a bridge rejection without losing its delivery-unknown mark

A host `RpcFailure` is data and rides in the reply untouched; a rejection of
`sendRequest` is the other path and needs rebuilding page-side. The mark that
says the request may already have run is a `WeakSet` on object identity, so it
cannot survive serialization and has to be re-applied, and the recorder reads
`error.constructor.name`, so the rebuilt error is named rather than plain.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): declare every message the web shell bridge carries

One schema per message in both directions, with `v` gating envelope shape and
`init.grants` gating capability. Unknown keys are dropped rather than refused:
the page bundle ships from a desktop that updates independently of the installed
shell. A reply payload is read through loose objects so a field a newer host adds
reaches the page unaltered, which is what the goldens record.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): split an oversized bridge reply instead of refusing it

The native screens have no reply byte cap, so refusing one at the frame cap would
invent a failure the phone does not have; source control's diffs would be first
to hit it. Frames are measured after serialization and only then accepted, so an
escaped control character or a surrogate pair cut across the boundary cannot push
one over. The absolute ceiling aborts the request rather than truncating a reply.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the bridge numbers rather than deriving every fixture

A test that builds its fixture from the constant it is checking moves with that
constant: widening the frame cap, the depth, the node count or the reply ceiling
left every boundary case passing. These numbers are wire between a released shell
and a page served by a desktop, so they are pinned as literals; the in-flight and
subscription caps had nothing holding them at all.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): bound the page's frames, not the desktop's answers

The depth and node caps exist to bound the cost of walking a hostile frame,
and only one direction is hostile. A 5 000-row listing reply carries 25 000
values, so holding the shell's answers to the same 20 000 node cap would
refuse ordinary data. `parseBridgeMessage` now takes the direction and walks
`page-to-shell` only; both directions keep the frame byte cap, and a chunked
reply keeps the 8 MiB ceiling as its single bound.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): carry a decoded screencast frame, not just its bytes

The binary `event` carried `b64` alone, but a binary listener is handed an
already-decoded `BrowserScreencastFrame`: format, metadata and the
screencast's own frame counter would all have been lost, and the envelope's
`seq` is the backpressure counter, not that one. The frame's fields now ride
beside the base64, mirrored field for field, with a compile-time pin that
nothing but the image is missing. C6 writes the encoder.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): fail to compile when the sender grows an option

The options pin only proved the schema accepts what the sender declares
today. A `Record<keyof SendRequestOptions, true>` makes the other direction
a compile error, so a new option cannot ship past the bridge unnoticed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): carry an error code of any shape, as the recorder does

The capture narrowed `code` to a string or a number, but the recorder
records whatever code it finds. A structured code would have crossed the
bridge as an absent field and moved a golden the day C0.5 replays through
it. Absent still means absent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): let the schema state the part bound on its own

The splitter's parts-ceiling branch could not fire: the largest reply the
ceiling admits, with every character re-escaping, splits into 26 parts
against a cap of 27. A branch no input reaches is a second statement of a
bound that drifts from the first. The derivation is pinned by a test now,
and `replyPartSchema` is the only place the bound is written.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound the ids a reply assembler holds at once

Nothing expired a half-assembled reply, so a host that sent a first part
and never a last one grew the map for the life of the page. A reply exists
only for a request the page made, so the in-flight cap is the right bound,
and the new id is the one refused. C0.4 owes the assembler a discard for
every request it settles.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refuse a screencast metadata field that is not a number

Only the compile-time pin stood between a metadata field and `z.unknown()`.
Every one of the nine is now exercised, so widening any of them fails a
test rather than only a typecheck.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): survive an error whose own getter throws

Reading `code` and `cause` runs whatever getter defined them, and both were
read in one parse, so a getter that throws took the capture with it: the
rejection path would have thrown where it had to produce an envelope. Each
field is read on its own now, and a throwing getter costs that field only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a surrogate pair whole across a chunk boundary

A pair cut in half encodes as two replacements, three bytes each, where the
pair whole is four. The sender cut by code unit and the assembler summed the
parts, so a reply within two bytes per boundary of the ceiling was refused
`reply-too-large` for bytes it never had, and each half-pair frame was not
well-formed UTF-8 for the native bridge to carry. The cut backs up one unit,
and the ceiling is measured once on the joined text. Code units still bound
what is held, since a reply is never fewer bytes than code units.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): always produce a capture, whatever the error does when read

`message` and `constructor` can be getters, and `String(value)` runs a
`toString` the thrower wrote, so reading an error is running someone else's
code. A throw there left the rejection with no frame at all and a promise
that never settles. The whole capture is guarded now, and the fallback still
carries the delivery-unknown mark, which is a `WeakSet` lookup.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make an error frame sendable by construction

A megabyte message or code is not a protocol error, it is a big string, and
it produced a frame the receiver refuses as oversized: a rejection the page
never hears. A cyclic code took JSON.stringify down with the whole frame.
Messages are truncated to 16 KiB and marked, a code is dropped when it will
not serialize or is past 4 KiB, and the worst chain the budgets allow now
measures 512 KiB against the 640 KiB frame cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a refused reply refused, and bound them together

Every failure dropped the id, so the next part opened a fresh accumulator:
a duplicate part then a whole set completed, and one id could feed 67 MB
through an 8 MiB ceiling one refusal at a time. A refusal is remembered now
and answers every later part, until the page discards the id. The bytes held
across all ids gain a ceiling of their own, since 64 replies at the per-reply
ceiling is half a gigabyte of parts that never complete.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say that a new enum member is not an additive field

The version rule read as though anything additive was safe. A value outside
a closed list is refused whole by the older side, so `end.reason`,
`binary.format`, `connection.state` and the foreground reasons are
negotiated, not appended. The byte-cap comment had its inequality the wrong
way round while I was there.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin what the guards keep, not only what they drop

Two mutants lived: clearing the assembler could have kept its tombstones,
and the guard around a cyclic code was hidden by the outer guard added for
a throwing getter. The capture is now asserted whole, so dropping the code
has to leave the message and the cause behind, and teardown has to forget.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): carry the code that was measured, not the one that made it

A stateful `toJSON` answers the budget check and the frame serializer
differently, so the snapshot is what crosses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 07:21:31 -04:00
Neil 01beadbcf0 fix(explorer): make filename search find all workspace files (#21423)
* fix(explorer): search file names through runtime

* fix(explorer): keep filename search results complete

* fix(explorer): narrow runtime search change

* test(explorer): remove unsupported local search assertion

* fix(explorer): fence filename search results
2026-09-18 04:05:23 -07:00
Neil 7909dad7ba fix(ci): keep mobile patches LF so Windows can parse them (#21439)
A Windows checkout CRLF-converted mobile/patches/*.patch because no
gitattributes rule covered them, and pnpm rejected the result with
ERR_PNPM_INVALID_PATCH, failing package (windows) and the verify
aggregate. config/patches/*.patch has been pinned -text for this exact
reason; mobile/patches/ was added later and never got the same rule.

git ls-files --eol showed all three mobile patches with an empty attr
against attr/-text on every config patch.
2026-09-18 03:42:48 -07:00
Neil 8f9a55ef8a fix(editor): restore editability after View Log (#21424) 2026-09-18 03:36:15 -07:00
Jinwoo Hong ca2ae89011 ci: install mobile dependencies in every desktop packaging job, pin mobile page source to LF (OTA phase C, C0.6) (#21425)
* chore(mobile): pin mobile page source to LF so a Windows checkout keeps buildId

The Phase C web bundle hashes every text byte under mobile/src and mobile/app into
its asset digests and from there into buildId. There is no global text=auto, so a
CRLF checkout on Windows would give the Windows release a different buildId for
identical source, the same failure the src/mobile-web pin above exists for.

All 1924 tracked files in those directories are already LF in the index, so the
pin renormalises nothing. mobile/web-entry does not exist yet; the pin is
forward-looking for the Phase C entry point.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci: install mobile dependencies in every desktop packaging job

Ten workflows reach build:release/build:desktop and none of them installs
mobile/node_modules. Root has no react-native, react-native-web or expo, so once
the mobile web bundle builds from mobile/ its packaging jobs would fail at
electron-builder's beforePack with an unresolvable import.

Extract the frozen mobile install that pr.yml's static analysis job already ran
inline into .github/actions/install-mobile-dependencies, and invoke it from every
job the packaging census enumerates, after the root install and before the build.
Same --frozen-lockfile, same lockfile-drift guard, and still no --ignore-scripts:
mobile's postinstall generates the gitignored webview engine modules that tracked
source imports. pr.yml now uses the action too, so there is one definition.

Where a packaging job's setup-node caches the pnpm store, mobile/pnpm-lock.yaml
joins cache-dependency-path so a mobile lockfile change invalidates it. Two jobs
(daemon-relocation-spike, win-update-survival-e2e) do not cache at all and are
left alone.

The census test grows a per-job assertion that the action is present, so a new
packaging job has to add the install deliberately rather than discover it at
beforePack. release-cut's composite-action restore is no longer Windows-only:
every platform consumes this action now, so any of them can be the leg whose cut
ref predates it.

No job builds anything different; this only makes mobile/node_modules present.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(ci): assert the mobile install contract on the shared action

pr.yml's static analysis job no longer carries the install inline, so the scope
test's findIndex by step name resolved to -1. Match the step by the action it
uses, and read working-directory and --frozen-lockfile off the action itself so
the job cannot keep the step while the action stops installing anything.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): exempt binary asset types from the mobile LF pin

/mobile/{src,app,web-entry}/** text eol=lf would mark a future PNG or font as
text and rewrite its bytes on a Windows checkout. Exempt the asset types an RN
page carries, the same way src/mobile-web exempts its PNG.

-text after text eol=lf wins: probed a CRLF-bearing .png under the pin, it stays
i/crlf attr/-text while a sibling .ts still normalises to i/lf. No tracked file
changes classification; the 1924 files under mobile/src and mobile/app stay i/lf.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci: gate the mobile install with the build it feeds in the cached lanes

win-crash-survival, win-update-survival and daemon-relocation-spike all skip
electron-builder on an installer/unpacked cache hit, so an unconditional mobile
install spent time on node_modules nothing then consumed. Move each `uses:`
below its cache step and carry the same cache-hit condition as the build.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 05:44:11 -04:00
593141590e fix(terminal): retire captured remote handles when pending panes close (#21005)
* fix(terminal): retire captured remote handles when pending panes close

A restored pane can hold a scoped `remote:<environment>@@<handle>` layout
binding while `remote.attach()` is still waiting for `terminal.resolvePane`.
The transport's `getPtyId()` is null, so an explicit split close passed null to
`closeWebRuntimeTerminal`, dropped the binding and destroyed only the viewer.
The host terminal stayed connected.

Only an exact scoped handle whose environment matches the owning workspace's
runtime authorizes the close. The provider helper captures the pairing
revision, runs its existing compatibility check, then rechecks pairing and
ownership immediately before dispatch.

Rebased onto main after #21001 was squash-merged. The previous head was a merge
commit that carried its own conflict-resolution content -- the runtime branch in
`terminal-pane-close-admission.ts` and the restored `it.each([false, true])`
parameter -- which a plain rebase drops along with the merge. Rebuilt from the
recorded net diff instead and verified byte-identical at 15 files,
906 insertions, 41 deletions.

* test(memory): rebase the pending runtime-close proof onto the squashed base

`fix.patch` recorded a baseline taken against #21001's pre-squash branch tip.
Squash-merging #21001 replaced that tip with a single commit, so the recorded
hunks no longer reverse-applied and `reproduce.mjs` aborted with
`Source changed: use-terminal-pane-close-actions.ts` -- confirmed by running it
before regenerating rather than assuming the rebase alone would fix it.

Regenerated against `main` and re-run: 5 pass / 10 fail before, 15 pass / 0
fail after, exit 0, and every `results.json` hash recomputed from the run
rather than hand-edited.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 02:13:11 -07:00
Neil 46d7ecf4d1 test(runtime): pin the merged-predecessor lockout the receipt ledger allowed (#20725)
The production change this branch carried - asking the receipt ledger the
same lineage-aware "is it retired" question as the recovery gate - landed
in the base branch (#19860) as part of "give 'same publisher' one answer
across the epoch fences". Rebasing onto that base leaves the regression
case, which is the part the base does not have.

`fences a merged predecessor at the recovery gate as well` stops one frame
early: it asserts the merged frame loses and never asks whether the live
successor still gets in afterwards. This case asks, for both the bare and
the merged shape. The bare shape passes without the ledger fix and is the
control.

Mutation: restoring `history?.retired.includes(publicationEpoch)` in
`recordReceivedWebSessionTabsSnapshot` fails only the merged-shape case.
2026-09-18 02:13:03 -07:00
Jinwoo Hong b749091b67 feat(mobile): native shell view serving a mobile web generation from a private origin (OTA phase B, 3/4) (#21417)
* feat(mobile): declare the orca-mobile-web-shell TS surface

Two props and one event: a generation directory the TypeScript store owns, a
session id that scopes the private origin, and a load state. No module
functions and no reload — a retry is a remount under a new React key, which
rebuilds the WebView and reinstalls every fence.

The native event body is a flat dictionary, so parseMobileWebShellLoadState
rebuilds the union instead of asserting it and answers null for anything it
does not recognise.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): serve a generation from a private origin on iOS

A WKWebView behind a custom-scheme handler that answers only from a map built
once from the generation's manifest, with the CSP as a response header on the
document. The scheme handler reads on a serial background queue and keeps a
live-task set that stop() removes from: an asset is up to 10 MiB, and
delivering to a stopped task raises an Objective-C exception Swift cannot
catch.

Origin, request refusal, the manifest map and the policy header hold no WebKit
type, so tests/MobileWebShellChecks.swift compiles and runs them with swiftc,
no device.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): compare the iOS shell's applied props field by field

One joined string could not tell a directory ending in the separator from a
shorter one with a longer session id. Two fields have no separator to collide
on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a string schemaVersion is not a manifest

The contract declares a number. The Kotlin side read it with optInt, which
coerces "1" to 1, so a manifest that widened the field would have been served;
this check covers the same shape on both platforms.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): serve a generation from a private origin on Android

A WebView behind shouldInterceptRequest, answering only from the same
manifest-built map as iOS, with the CSP as a response header on the document.
The origin host label is a slice of the session id's SHA-256, never of the
session id: Chromium lowercases an https host and java.net.URI reads null for
a label holding '_', which is how the reference 403'd every asset.

A main-frame failure is reported from a post() because Chromium commits its
own error document after onReceivedError returns. onRenderProcessGone destroys
the dead WebView and does not rebuild it, so the retry policy stays in one
place. clearCache(true) is never called: it is process-global and would wipe
the terminal WebView's cache too.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): untrack the shell module's gradle build output

The previous commit staged 312 files from android/build. mobile/.gitignore
anchors /android/ at the mobile root, so a module's own gradle output was
never covered.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): parse the shell load-state payload with a zod shape

The anti-slop gate rejects an `object` parameter and `Reflect.get`. zod reads
a shape key straight off the value, so the own-property strip stays.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the web shell one load-state machine per platform

A failure is terminal, and a repeat says nothing. Chromium commits its error
document after onReceivedError returns and a rule list compiles long after a
generation was refused, so both platforms could report over a failure the
caller had already acted on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the Android shell reporting ready over a failed document

onPageFinished ran after reportDocumentFailure's post and both emitted `ready`
and set the WebView visible again, putting Chromium's error page on screen. A
prop change after the renderer died now reports instead of going silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): publish the Android shell's served generation atomically

The map and the host it is keyed against were two plain fields written on the
main thread and read on Chromium's, so an interceptor could see a stale null
and 403 a good frame, or a new map against the previous host.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the iOS shell to one terminal load state

A rule list that failed to compile after a generation was already refused
emitted a second, contradictory reason. The document-failure flag it carried
is now the state machine's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop serving the previous generation after a failed prop update

Both platforms returned early with the old map still installed and the old page
still on screen, so a caller told the shell had failed was looking at a working
one from the generation before.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): serve the shell document at "/" and nowhere else

/index.html answered the same bytes without the policy header, which rides the
document response alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the shell's response headers as a pure predicate

Which response carries the policy header was decided inside the two request
handlers, where no test without a device can reach it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the shell's path-length edge and its charset casing

Both limits were checked only from the rejecting side, so a one-off length and
an uppercase charset passed unnoticed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): state the Android shell's file-URL settings and what B4 must check

The two file-URL settings were left to their defaults, and the settings that
only a device can prove named nobody to prove them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): drop the shell module's unresolved entry points

Nothing imports the module by name, on either side; the TypeScript is reached
by path, as the notification-dismissal module's is.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stop the iOS shell failing a document it cancelled itself

stopLoading on a prop update and every navigation the policy delegate refuses
reach the failure delegates as errors, so a healthy page reported `failed`,
lost its `ready`, and sent the caller to delete a good cached generation.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): answer when the iOS rule list store is missing

Optional-chaining past a nil store ran no completion handler, so the view
stayed at `loading` for good. The next prop update now reads the same terminal
isolation failure a compile failure sets.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse a manifest whose schemaVersion is true or 1.0 on iOS

NSNumber bridges both to 1, so `as? Int` accepted a manifest Kotlin rejects.
Verified against JSONSerialization: objCType is c for true, d for 1.0, q for 1.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop an Android document failure the next load did not have

The report is deferred past Chromium's error document, so a prop update could
land between the decision and the report and fail the generation that had just
replaced the one that actually failed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): assert each blocked global's descriptor whole

contains("writable:false") passed on a WebSocket descriptor that had lost it,
because the serviceWorker copy still carried one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the Android shell's navigation and refusal decisions

Both lived inside the WebViewClient, which no suite compiles, so dropping the
navigation guard or answering a refusal with 200 changed nothing anyone could
see.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the domain a policy-cancelled frame load is reported under

WKErrorDomain has no frame-load codes: WKErrorCode stops at the app-bound
domain errors, and 102 belongs to the legacy WebKitErrorDomain. The iOS SDK
exports no symbol for it, so the assert that pinned one is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 05:03:30 -04:00
Jinwoo Hong db2ffe7afe fix(mobile): default injected timers to receiver-free wrappers (#21416)
* fix(mobile): default injected timers to receiver-free wrappers

Every transport class stored a global timer function on an object and then
called it back through that object, so the receiver was the instance or the
dependency bag rather than the global. Hermes ignores the receiver; browsers
reject it with TypeError: Illegal invocation, which makes the web build fatal
at the first retry, liveness probe, or relay grace timer.

Default each injected timer to a wrapper that calls the global receiver-free,
and narrow the seam's type from `typeof setTimeout` to the call signature it
actually uses. Node's `typeof setTimeout` also demands a `__promisify__`
member that no injected timer or wrapper can supply, so the wrapper cannot
satisfy it. Pruning mobile-relay-background-grace.test.ts from the typecheck
baseline follows: the narrower type makes that file check clean.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the default timers against a browser receiver check

Both classes are now constructed with no injected timers under a global
setTimeout/clearTimeout that throws Illegal invocation for any explicit
non-global receiver, mirroring the WebIDL rule. The watchdog gets its own
file because its existing test is grandfathered out of the typecheck ratchet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): prove the default clear leg and drop bare timer injections

The clear assertions were vacuous: cancel() and stop() also drop the state a
fired callback checks, so a no-op default clearTimer stayed green. Both tests
now assert the wrapped global clearTimeout received the exact handle setTimeout
returned, which fails when that default is mutated to a no-op.

Three relay tests injected bare setTimeout/clearTimeout into dependency bags,
the same receiver shape the product fix removed; inert under node, fatal under
jsdom. relay-host-signed-out-verdict drops two `as unknown as typeof setTimeout`
casts, since ScheduleTimer now types those arrows contextually.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census bare global timers parked in properties and defaults

mobile-endpoint-lifecycle could regress to bare globals with every other test
green, because nothing there is reachable from a unit test. Walk every product
file's AST and fail on a global timer parked where a later call reaches it
through a receiver: a `??` or `||` default, an object literal member, or an
assignment onto a property. A plain local capture stays legal, since calling it
bare leaves the receiver undefined.

A separate test asserts the walk sees the five fixed sites' wrapper shape, so an
empty or misdirected scan fails instead of passing vacuously.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): define the receiver-free timer defaults once

Five hand-written wrappers each restated the same invariant, so five places
could drift. timer-scheduler now exports defaultScheduleTimer and
defaultCancelTimer, and carries the reason for them; every site takes its
default from there. The census keys its presence precondition on those two
identifiers instead of the arrow shape.

The census also missed `??=` and `||=`, which park a global exactly like their
non-assigning forms. Both are handled now, with a parsed-source case per parking
form and one for the local capture that stays legal.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 04:59:56 -04:00
Neil 5c2d3322c1 fix(runtime): name a terminal whose pane a graph republish dropped (#19860)
* fix(runtime): name a terminal whose pane the graph dropped

`buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement
with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose
`paneKey` still parses to its own `tabId` passes that forever, including long
after the session graph dropped the pane, so a terminal that had lost its
surface reported `orphaned: false, connected: true, writable: true` and a
`tabId` no tab has: field-for-field identical to a healthy one (#18191).

Consult the leaf topology instead, gated on a graph statement having had the
standing to contradict the record. `graphSequence` counts authoritative graph
statements; every statement re-records the surface of every pane it publishes,
so a pane the current graph holds carries the current stamp and is answered
without touching the leaf map. That covers the two absences that are not
evidence, without a second flag: a surface recorded since the last statement
(spawn records the pane before the graph carrying it arrives, #7587), and a
lost graph clearing every leaf at once without advancing the sequence. A pane
already observed dropped keeps its stale stamp and stays named, because losing
the ability to re-check is not a reason to un-see it.

`orphaned: true` is shipped vocabulary that both consumers already read, so no
capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and
now reaches this population, and the duplicate-surface index
(`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's
live owner.

* fix(runtime): publish a terminal retirement proof on the exit's own evidence

A paired client may drop a mirrored terminal on exactly two kinds of host
evidence: a `retiredTerminalSurfaces` proof naming the handle, or two
authoritative `terminal.list` inventories that omit it. The second needs two
host publications, and a quiet workspace publishes one, so the proof is the
only evidence that rides the frame carrying the retraction.

That proof was minted only as a byproduct of persistence *accepting a change*,
which made one value carry two meanings: "a change was accepted" and "the PTY
exited". The host renderer's close transaction de-persists the surface and
republishes without it, so when it got there first the exit found nothing left
to accept and the attestation died with it. Measured on a real paired client:
the host retracted in under 500ms, published no proof, then froze its
snapshotVersion for 60s while the client kept a dead pane in its tab bar.

Persistence still gates *removal* — publishing absence before the membership
fence is durable would let a crash resurrect the surface. It no longer gates
the proof: the observed exit is itself the attestation.

The exit-first ordering already had a passing test; the renderer-first ordering
had none, and that is the one users hit. Both orderings are now pinned, with
exit-first as the control that makes the renderer-first failures mean something.

Wire: `retiredTerminalSurfaces` is an existing optional field on an existing
path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is
Rule 1 — an old client that ignores it degrades to the two-inventory route it
already uses today, so no capability gate is needed. The sentence "the host
starts sending a frame it did not send before" reads like Rule 3; it is not,
because the frame shape, the field, and the reader contract are all unchanged.

* test(runtime): pin the removal frame retiring a still-live publisher

KNOWN RED (`it.fails`), no product change. Found while verifying the close
retraction fix: once the emptying actually reaches paired clients — a state the
previous behaviour never allowed, because nothing propagated — re-adoption of a
later create is flaky. Measured 1 failure in 6 runs of the two-client journey.

`decideWebSessionTabsSnapshot` treats the host's synthetic `removed:<t>`
retraction as a publisher handover: it retires the still-live renderer epoch and
installs the retraction as current, while the removal also clears the live
freshness record. The next frame from that same running publisher then matches
no lineage and reads as a retired generation, so it is outranked and the
publisher is locked out of the worktree until its generation changes.
`local-structured-session-tabs-sync/snapshot-apply.ts` documents this exact
scenario and has a revive escape; the mirror path has none.

The suffix case explains the 1-in-6: `hasRetiredValue` is an exact string match,
so a republication carrying `:headless-merge:` walks past the fence and only a
bare same-epoch republication is locked out.

Not fixed here on purpose. Dropping the retirement makes the red case pass but
breaks `web-session-tabs-sync.test.ts > keeps a removed worktree fenced against
delayed predecessor epochs`, which asserts a same-epoch higher-version frame
after a removal must be rejected. At this layer those are the same frame — this
function holds no `receivedFrame`, so it cannot separate a delayed predecessor
from the live publisher speaking again. The fix belongs in
`shouldApplyRecoveredWebSessionTabsSnapshot`, which does hold that ordering and
currently defers to the same epoch fence. That is a contract change across two
functions and an existing invariant, not a one-liner.

* fix(runtime): a removal retraction is not a publisher handover

The host drops a worktree's entry when its last tab closes and announces it
with a synthetic `removed:<t>` epoch. Both receipt sites treated that as a
publication: `decideWebSessionTabsSnapshot` and
`recordReceivedWebSessionTabsSnapshot` each noted the retraction epoch as
current, which pushed the still-live renderer epoch onto `retired`. The removal
also drops the live freshness record, so the next frame from that same running
publisher matched no lineage, read as a retired generation, and was outranked.
The live publisher was locked out of its own worktree until its generation
changed. That is fail-closed, and it is why re-adoption after an emptying was
flaky once the emptying actually reached paired clients.

A retraction and the live publisher's next frame are the same epoch at a higher
version, so epoch identity cannot separate them and never could. Delivery order
can. `recordReceivedWebSessionTabsRemoval` now records the retraction as the
worktree's newest received evidence instead of deleting the ledger, so
`shouldApplyRecoveredWebSessionTabsSnapshot` — the gate every production apply
path passes before `decideWebSessionTabsSnapshot` — fences a frame that
reserved its received frame before the retraction while admitting one that
arrives after it. The boundary carries the retraction's own epoch, which never
matches a host publication, so a later live frame may still restart its version
counter. `local-structured-session-tabs-sync/snapshot-apply.ts` documents the
same conclusion for the local path: a retired epoch is not proof of a dead
generation.

`keeps a removed worktree fenced against delayed predecessor epochs` pinned the
delayed predecessor at the raw decision layer, which is the same call as the
live publisher's republication. It now pins the identical scenario — same
epoch, higher version, still rejected — through the receive-and-apply path that
actually holds the ordering, plus the composed gate as production spells it.

The committed `it.fails` repro is not sufficient on its own: it records no
received frame, so dropping only the `decideWebSessionTabsSnapshot` retirement
turns it green while the publisher stays locked out on every real path. A
receive-and-apply case is added alongside it to close that gap.

* test(runtime): pin the retraction boundary against a stale inventory omission

Mutation testing left a survivor: writing the boundary unconditionally, instead
of only when it advances the ledger, passed the whole runtime suite. It is not
inert. A visibility-resume inventory reserves its received frame before it
lists, so an omission it reports can be older than a stream frame that landed
meanwhile; without the guard that stale omission rewinds the ledger, forgetting
the stream frame's version, and a delayed list reserved in between is then
readmitted instead of outranked. This pins that ordering.

The one remaining survivor is the boundary's `snapshotVersion`, and it is inert:
the ledger's version is read at exactly two sites, both reachable only when the
incoming frame's epoch equals the stored one, and a retraction epoch never
equals a live publication.

* test(runtime): cover the fences the retraction change narrowed

Two gaps found by mutating the fences themselves rather than the fix.

Deleting the epoch fence in `shouldApplyRecoveredWebSessionTabsSnapshot` passed
the entire runtime suite. It is not unreachable: a superseded generation whose
sibling stream delivers its frame after the handover outranks the successor on
delivery order, and only the retired-epoch check rejects it. Retractions used to
exercise that fence too; now that they no longer retire anything, a genuine
handover is the only thing left that reaches it, and nothing covered that. The
fence is narrower than it was, not dead.

The second case pins rate-independence. The defect surfaced 1 run in 6 because
`hasRetiredValue` is an exact string match while `sameSessionTabsPublicationLineage`
treats `:headless-merge:` as the same publisher, so a merged republication walked
past a fence a bare one hit. The removal path is now asserted over both epoch
shapes through the full path, so a fix that only re-rated the defect instead of
removing it would fail here.

* fix(runtime): give "same publisher" one answer across the epoch fences

Separable from the retraction fix beneath it, and it changes handover-path
behaviour: a superseded generation that republishes under a merged epoch is now
rejected where it was previously accepted. Take it independently or not at all.

`publisher-identity-fences.ts` held two answers to "is this the same publisher".
`noteRetiredValue` treated a `:headless-merge:` epoch as a SUCCESSOR of its base
and retired the base when the merged form became current, while
`sameSessionTabsPublicationLineage` treated the two as ONE publisher. Those are
contradictory, and the retired-value check's exact-string match was the shim
that kept them from ever meeting: a merged frame was a different string, so it
never looked retired no matter what had been retired.

The cost was that the same predecessor was accepted or rejected depending on
which shape it arrived in. A generation a successor had replaced was fenced when
it republished bare and admitted when it republished merged — the fail-open half
of the same disagreement whose fail-closed half was the removal defect, and the
reason that defect reproduced 1 run in 6 rather than every time.

This cannot be fixed in the fence alone. Making the fence lineage-aware while a
merged epoch still retires its base has the generation retire itself: the
rebuild arrives, retires its own base, and the fence then rejects it as a
retired generation. So both sides move together — a lineage sibling advances the
current epoch instead of superseding it, and inherits its generation's
retirement instead of escaping it.

Scoped to the publication-epoch functions. Runtime-id retirement keeps exact
matching, and `local-structured-session-tabs-sync` keeps its own
`hasRetiredValue` call, where a lineage sibling is already excused explicitly
and a retired epoch is deliberately not treated as proof of a dead generation.

* test(e2e): journeys for a reopened client and two clients on one host

Two gaps this suite had no coverage for, both driven end to end against a real
paired desktop client rather than at a seam.

A relaunched client holding a live remote terminal: every paired restart spec
here restarts around a browser pane, none around the terminal the user is
actually mid-work in. The host-side fixture's on-disk sink is the oracle — one
READY for the whole run proves the host never re-spawned the session, and a
recorded line for input sent after the relaunch proves the restored pane is
wired to that same process rather than painted with its scrollback.

Two clients on one host across an emptied workspace: the tombstone is
client-local on the runtime path, so a client that never held a row still seeds
into a workspace another client deliberately emptied. That asymmetry is by
design; a client falling out of step with the host and staying there is not.
Phase 0 is the control — without it a later divergence cannot be attributed to
the emptying rather than to mirroring never having worked.

The input probe goes through `pane.terminal.input`, not `window.api.pty.write`:
a mirrored pane's handle is a `remote:` id that no local PTY answers to, so a
direct write is swallowed and the assertion passes on nothing. The pre-restart
control exists to catch exactly that, and did.

* test(e2e): keep the two-client journey spec type-clean

* test(e2e): pin the close retraction a paired host does not publish

* docs(e2e): say why the red close-retraction spec sits on this PR

The spec was written on a branch carrying neither of this PR's publish-side
fixes, and its own diagnosis -- the fault is the host's publish-after-close,
not any client's mirror -- names exactly what they change. Landing it here
makes CI the measurement rather than leaving a red spec parked on a branch
with no fix in it.

Records the one thing a reader needs to not do: skip-tagging it. And why the
obvious split is not a block move -- phase 2 depends on phase 1b's emptying
and both share the two-client pairing fixture, so splitting means duplicating
the fixture.

* test(e2e): the close-retraction spec is green on this branch, measured

It was written to pin a defect and was red where it was written. On this
branch, with `publish a terminal retirement proof on the exit's own evidence`
and `a removal retraction is not a publisher handover` both present, it passes
-- twice, independently: phase1a A=9ms/B=158ms then A=2ms/B=1ms, against a
prior baseline of "none reached either client within 90 seconds".

So the KNOWN RED header had become the thing it warned about: a test carrying
prose asserting the very behaviour the commits beside it remove. Rewritten to
record the measurement and the numbers to regress against, and to keep the one
instruction that still applies -- if it reddens again, do not skip-tag it; the
failure shape is a 90s timeout on both clients at once while creates still
propagate.

No assertion changed. Comment only.

* test(wire): pair the session-tabs retirement proof across two builds

The stack makes a host start sending a retirement proof on its own frame
when no surface removal carries one. The change argues Rule 1; Rule 3's
fourth bullet covers a frame the host starts sending on an existing path,
so the claim is measured against v1.4.199 rather than accepted.

Neither existing cross-version suite reaches session-tabs: the terminal
one covers the binary stream, the agent-session one covers agentSession.*.

Result: the old client acts on the proof-only frame, because the whole
client half of this surface is unchanged. The old-host cells are pinned
to a release that cannot publish the frame at all, which is what makes
the new-host cells mean something.

* fix(lint): clear the casting gate on the surface-lost inventory

main tightened typescript/consistent-type-assertions to assertionStyle:
never, which the rebase brings onto these added lines. The retraction
read narrows on the property instead of casting; the fixture and
cross-build-import casts carry per-site SAFETY rationales.

* fix(lint): bind the protected-stamp cast to a name

The leading-semicolon parenthesised call put the suppression on a line
oxfmt then reflowed away from the assertion it covers. Naming the
narrowed handle keeps the directive next to the cast.

* fix(runtime): route every non-null surface write through the stamped writer

`ptyHoldsRecordedSurface` trusts a record only while its stamp is current;
after that the leaf map answers. Four writers still named a pane with a bare
`tabId = / paneKey =` — orphan adoption (both branches), split, create on an
adopted stable pane, and TUI-owner recovery — so a record that had already
been contradicted stayed contradicted after the claim, and `terminal list`
reported the just-claimed PTY `orphaned: true` until the renderer's next
graph statement re-recorded it. Before this branch those sites read as
attached at once, so this was a regression window of one round-trip, and
`indexLiveTerminalSurfaceOwners` reads `orphaned` as "unowned".

`recordPtySurface` is now the one writer; the adoption module reaches it
through a port because it has no `graphSequence` of its own. The nulling
writers are untouched: a null surface is never held, stamped or not.

* test(runtime): keep one copy of each publisher-fence case

The removed-frame suite asserted four properties that another case in the
same suite or the lineage suite already pinned:

- the decide-only readmit and the bare full-path readmit are the bare arm of
  the parameterized full-path readmit, verbatim;
- the merged-suffix decide-only readmit is the merged arm of the same loop;
- "still fences a predecessor a successor replaced" is the lineage suite's
  bare arm with different version numbers;
- the recovery-gate handover case is the lineage suite's recovery-gate case
  with a bare late frame instead of a merged one, so that test now runs both
  shapes and this copy goes.

Mutation-checked: reverting each of the five renderer changes on this branch
(retire-on-removal in decide, noting a retraction current, the exact-match
retired fence, merge-supersedes-base, dropping the ledger on removal) still
fails at least one of the remaining ten cases.

Also corrects the suite header: a retraction carries a synthetic `removed:`
epoch, so it is the in-flight predecessor frame, not the retraction, that
shares the live publisher's epoch and needs delivery order to be separated.

* test(e2e): fail the two-client journey when phase 1a cannot run

Phase 1a sat inside `if (beforePartialClose.length > 1)`. A host workspace
that starts with one terminal skipped the control silently while 1b and 2
still ran, and the spec passed green without ever exercising the
close-with-others-open retraction it was written to measure. The skip is now
a recorded failure naming the host count.

* fix(runtime): order every session-tabs apply path against the retraction

A closed terminal came back on the other client because "this worktree was
retracted" was neither durable nor universal:

- `refreshWebRuntimeSessionTabsSnapshot` reached `decide` with no place in
  receipt order at all, so a list the host answered before the close applied
  after the retraction had already cleared the worktree. It is a production
  path for close, create, activation, split and PTY reconnect.
- the boundary lived in a single receipt slot the next stream frame overwrote,
  and in a fence that only existed when a recovery happened to be pending when
  the retraction landed, so a pre-close list could out-rank the republication
  on `snapshotVersion` alone.

Replace both with one raise-only removal watermark per (environment, worktree)
and give the list path a receipt position, reserved by the request and carried
in its answer so a dedupe joiner inherits it rather than minting a newer one.
The pending-recovery fence and its bookkeeping are dead once the boundary is
monotonic. The exact-match retirement check in the receipt ledger becomes the
one lineage-aware predicate, so a `:headless-merge:` rebuild can no longer be
noted as current and retire the live publisher out of its own worktree.

On the main side, `recordPtyWorktree` stamped `surfaceRecordedAtGraphSequence`
at write time, so any `paneKey` write claimed the standing of a fresh graph
statement. The inventory restore in `terminal list` therefore un-dropped the
very pane the read was meant to report, on every listing. A surface claim now
carries no graph standing unless its writer names one: the graph statement,
live leaf output and spawn do, while the inventory restore, the floating
liveness restore and the mobile projection replay do not. Defaulting this way
means a writer that says nothing fails safe and self-corrects, which the type
alone could not guarantee across the projection contract's own `recordPty`.

Spawn claims now span the one graph statement the renderer may already have in
flight, and retirement proofs compare by identity instead of by position, so a
re-delivered exit no longer fans out a `snapshotVersion` bump carrying nothing.

* fix(runtime): stop an unpublished-worktree placeholder retiring the live publisher

A worktree the host has published nothing for still answers a forced list, with
a synthesized `none`/v0 frame that means "ask me later"
(host-session-snapshot-authority.ts). Every post-close list and every
activation of an emptied worktree gets one. Noting it as a publication retired
the renderer generation that is still live, and because that epoch is
per-process, the terminal the user created next never reached this client — the
same lockout the retraction path was already careful to avoid, through a door
it did not cover. `local-structured-session-tabs-sync` already skips the
placeholder for this exact reason; the web mirror now does too, on both the
receipt ledger and the frame decision.

Bound the receipt ledgers by frame age rather than entry count. One bootstrap
inventory records a receipt per worktree under a single reserved frame, so
evicting by insertion order dropped that batch's own earlier entries, and an
absent receipt is what the recovery gate reads as "no evidence for this
worktree". Only a receipt no in-flight frame can still be ranked against is
droppable.

Take the receipt gate off the `web-session-tabs-sync` barrel in the refresh
path. Ordering is that path's gate, not an optional collaborator a caller's
module mock may leave out, and being reachable only through the barrel is how
the path came to have no ordering at all.

* fix(runtime): let the TUI-owner recovery name its pane without claiming the graph holds it

`recoverStructuredTuiOwner` rebinds a recovered PTY from the persisted owner
binding — the same replayed-evidence class as the inventory restore — but
stamped it with the current graph sequence, so a pane the renderer had already
dropped read as attached for one more statement. The guard below it needs the
tabId and paneKey, not the standing.

Also say plainly in `decideWebSessionTabsSnapshot` what the affirms check does
and does not cover: an unpublished-worktree placeholder is withheld from epoch
noting only. It still applies, because rejecting it outright would drop the
terminal reconciliation that legitimately rides on it.

* fix(runtime): keep the retraction boundary out of the receipt bound

Bounding the removal watermark alongside the receipt ledger reintroduced the
defect the watermark exists to prevent: past 512 retracted worktrees, evicting
a boundary readmits every pre-close frame it was fencing, and a delayed list
resurrects the closed tab. A boundary is not a cache. One number per worktree
ever retracted on an environment is the cheaper price, and environment teardown
drains it; only the receipt ledger stays bounded, by frame age.

Split the orphan-adoption port by provenance so the last writer that disagreed
with the surface-standing rule stops disagreeing. `adoptRuntimeTerminalOrphans`
replays the persisted binding when the claim already matches it and writes a
new one otherwise, and both went through a single `recordSurface` that stamped
the current graph sequence — so re-adopting an already-adopted orphan lifted a
dropped pane's stale stamp and reported it attached, in a quiet workspace
possibly forever. The replay now names the pane without standing and the fresh
claim takes spawn standing, like every other writer.

Replace a receipt-count assertion that was vacuous for a map keyed by
environment and worktree with the mirror state and freshness it was standing in
for.

* fix(runtime): keep a closed-tab worktree under the epoch already publishing it

`closeHeadlessMobileTerminalTab` minted `headless:<now>` on every close. Its
sibling headless writers carry the stored `publicationEpoch` forward and mint
only when there is no snapshot to inherit from — because a write to a worktree
is not a claim to publish it. The close was the one writer that claimed.

A paired client retires the epoch a new publisher displaces, and the web
mirror's retirement is final: there is no revive lane, and the per-worktree
tracking teardown deliberately keeps the epoch history. So an ordinary close
published a stranger for a worktree the renderer generation still owned, retired
that generation on every client, and the renderer's next publication — carrying
the epoch the close had just retired — was rejected forever. The user emptied a
workspace, created a terminal, and it never arrived on either machine while
`session.tabs.list` showed the host holding it.

This is the same thesis the retraction path already states, through the door
next to it: a retraction is not a handover, and neither is a close.

Measured on `paired-two-client-emptied-workspace-reseed.spec.ts`, six runs each:
phase 2 failed 3/6 before (`A=null B=null`, both clients blind for the full 30s
budget) and 0/6 after, with both clients adopting in single-digit milliseconds.

* fix(lint): give the fixtures real types instead of casting past them

The casting gate failed on eight assertions this branch added. All eight were
suppressible, but the suppressions were not the problem: the casts were hiding
fixtures that did not match the contracts they stood in for.

`sessionStillHoldingBothPanes` built tabs as `{id, title, type}` — `type` is not
a `TerminalTab` field and eight required ones were missing — and layouts holding
only `ptyIdsByLeafId`. `as never` made both compile. They are now real
`TerminalTab` / `TerminalLayoutSnapshot` values, so the fixture is checked
against the type `listTerminals` actually reads.

`terminalTab` in the epoch suite built a *client* tab (`status`, `terminal`) for
a field typed with *snapshot* tabs, which forced `as never` at the call and a
cast on the snapshot itself. Production reads only `type`, `parentTabId`,
`leafId`, `ptyId` and `parentLayout` from that tab, so the two client-only
fields were inert; dropping them lets the declared
`RuntimeMobileSessionTerminalTab` type the fixture end to end, and the closed tab
is now held by name rather than recovered from `snapshot.tabs[0]`.

The remaining three casts are unchanged in kind and now carry correctly placed
SAFETY rationales: reaching a protected member is the only way to drive these
paths. `graphSequence` folds into the reach-through that was already there
rather than opening a second one, and the map read narrows instead of asserting.

Mutation-tested, all three suites, regression re-introduced for each:
- epoch mint on close restored -> 1 failed | 1 passed
- orphan check reverted to self-consistency -> 4 failed | 4 passed
- placeholder retirement guard removed -> 1 failed | 7 passed

src/main/runtime 8169 passed | 31 skipped; src/renderer/src/runtime 1581 passed.
`check:code-quality:changed` goes 8 findings -> 0. `pnpm tc` clean.

* fix(runtime): stop the headless placeholder graph from dropping every restored pane

A headless server publishes one empty graph at launch so status clients see a
ready server. It names no renderer pane and is never replaced, but it was
counted as an authoritative graph statement all the same: `graphSequence` went
0 -> 1 while the leaf map stayed empty for the life of the process.

Every surface claim written without standing - a persisted replay, an inventory
restore, the TUI-owner recovery - is stamped 0. Against `graphSequence` 1 the
`>=` guard fails, the empty leaf map answers "no pane holds this", and the
terminal reports `orphaned: true` under a `pty:` tabId. Nothing can re-stamp it,
because the only graph that host will ever publish has already been published.
On a headless or SSH host that is permanent, and it is the same lie #18191 is
about, pointed the other way.

The placeholder no longer spends a graph statement. A renderer graph still does,
so a pane a real graph drops is still reported dropped - including on a desktop
window promoted from headless, which the third case pins as a negative control.

Mutation: restoring the unconditional bump fails the first two cases
("expected 1 to be +0", "expected true to be false"); the promoted-window
control passes either way, as a control should.

Also registers tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
in the cross-version-wire job. The file matches CROSS_VERSION_WIRE_PREFIXES, so
adding it had switched the job's gate on, but the job runs an explicit file list
that omitted it - the test executed nowhere in CI. It passes 8/8.
2026-09-18 01:56:07 -07:00
Jinwoo Hong 002ff3ddb8 feat(mobile): per-host generation store for the mobile web bundle (OTA phase B, 2/4) (#21409)
* refactor(mobile): export the mobile web manifest read schema

The generation store re-parses the manifest it cached, and it must read it back
with the same loose reader the fetch accepted it under: parsing strictly after
accepting loosely would turn a host's added field into a forced redownload on
every launch.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): add the per-host mobile web generation store

Turns a verified bundle into an atomically activated, host-scoped generation
directory under the OS cache, and reads it back. No RPC, no UI, no flag: the
native view is later handed the directory read-only and never writes to it.

The single directory under `generations/` is the activation, so there is no
activation file to edit: a commit deletes every other generation before the
rename, an interrupted one leaves zero generations for the runbook's redownload
rule, and two directories or an unreadable manifest drop the host tree instead
of guessing. `tmp/` is never an activation candidate and every one of them goes
at launch. `hosts.json` carries recency only, so losing it costs eviction order
rather than a generation.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never evict the host a commit just activated

`now()` is a wall clock. With four hosts cached, one backward jump made the
fifth commit's own entry the oldest, so it evicted the host it had just
activated and handed back an ActiveGeneration whose directory was gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* Revert "fix(mobile): never evict the host a commit just activated"

This reverts commit a082ac1777. That commit carried all six round-1 fixes under
a subject naming only one of them; the six land again below, one per commit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the generation store's unused eviction entry point

`evictHostsBeyond` had no caller: commit enforces the four-host ceiling itself,
and a launch-time sweep for a shrunk limit can be added when something shrinks
it. The two `createDirectory` calls went with it, since the port already creates
intermediates, plus a line on what the Android rename fallback leaves behind.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never evict the host a commit just activated

`now()` is a wall clock. With four hosts cached, one backward jump made the
fifth commit's own entry the oldest, so it evicted the host it had just
activated and handed back an ActiveGeneration whose directory was gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fold case when refusing an asset named like the manifest

APFS and NTFS are case-insensitive by default, so `Manifest.JSON` landed on the
store's own `manifest.json` and the activation read back as the asset's bytes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a completed activation when the recency index cannot be written

`hosts.json` is written after the rename, so a disk that filled between the two
turned a generation already on disk into a thrown commit. The index carries
recency, not truth, and the next activation rewrites it whole.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse to commit a staged handle whose tree is gone

Commit deleted every other generation before it looked at the staged tree, so
committing an aborted or swept handle destroyed the live generation and only
then threw. The check moves ahead of the first delete.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): close four surviving generation-store mutants

Sweeping only the first host's tmp, staging over residue, dropping the serial
queue, and dropping the stale-index pruning all passed the suite. The stage
race needed two differing asset lists under one build id to be visible at all:
with identical ones an interleaved pair ends on the same bytes as a serial one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): activate only a build-id entry that holds a manifest

An entry under `generations/` matching the staged build id was taken as the
activation on its name alone, so an empty directory of that name — what a
crash between the rename and the post-rename check leaves on Android under
API 26 — or a plain file made the commit drop the verified staged tree and
return a generation that cannot be read back.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): never delete a cache because a manifest read failed

The adapter mapped every `file.text()` throw to null and the reader treated
null as corruption, so one iOS data-protection or I/O blip deleted the only
verified generation a host had. Missing stays null and still drops the tree;
a failed read now throws, and the reader returns no activation without
touching disk, leaving the caller to redownload.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the generation store's unreachable build-id guard

`MobileWebBundleManifestReadSchema` already pins `buildId` to the sha256
pattern, so no manifest reaching the store can fail the second check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that eviction ignores a non-host directory

Dropping the host-key filter in `listHostDirectories` passed the whole suite;
the ceiling would then count and evict anything else under the OS cache
directory.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep a mid-download host out of the cache ceiling

A host holding only a staging tree was counted against the four-host limit
and, having no index entry, sorted first for eviction, so four cached hosts
plus one download meant the next activation deleted the tree that download
was about to commit. The ceiling now counts hosts with a generation; sweeping
still walks every host directory.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): record recency when a commit finds the build already active

The same-build early return skipped the index write, so a host that
redownloaded the bundle it already had stayed the least recently activated
and was the first evicted. No eviction pass on that path: the host count is
unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): honour only staged handles the store itself issued

`StagedGeneration` is structurally typed, so any object of that shape made
`commitGeneration` rename over, and `abortStagedGeneration` delete, a
directory of the caller's choosing. Handles are tracked in a per-store
`WeakSet` and anything else is refused before a filesystem call.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 04:44:36 -04:00
Jinwoo Hong 1e7a69710d feat(mobile): update wall for the desktop-served mobile web bundle (OTA phase B, 1/4) (#21411)
* feat(mobile): decide whether a web bundle may open against its host

A pure verdict for the bundle update wall, ordered so the answer names the
soonest cause: a host with no bundle has no manifest to disagree about, and an
unknown manifest schema makes the protocol window inside it unreadable. Same
`?? 0` defaults as `evaluateCompat`, so an absent status field reads as the
oldest host that could have answered rather than as permission.

Every blocked verdict is terminal. There is no native workspace fallback, so
each one carries the numbers it compared for the support breadcrumb.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): read an unknown bundle schemaVersion through to the wall

The client reader pinned `schemaVersion` to the one schema this shell knows, so
a future schema 2 failed the parse before `evaluateMobileWebBundleCompat` could
call the shell too old. The user would have seen a transport error where the
update wall belongs.

The host's own manifest stays closed in both directions, where it is written.
Goldens are unaffected: every recorded reply carries schema 1.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the block screen copy for the bundle walls

One component still renders every wall. `updateSide` picks the app to update
from the reason, so the copy and the store link cannot disagree, and a new
reason is a compile error there rather than a mobile title over a desktop
button. The existing protocol copy is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): type the bundle protocol window the update wall compares

The loose reader left `runtimeProtocolVersion` and
`minCompatibleRuntimeProtocolVersion` as unknown index members, so the parsed
manifest could not reach `evaluateMobileWebBundleCompat` without a cast. Both
are now read as non-negative ints, and the reply-schema test pins it at the
call site: the wall is invoked on a parsed manifest, so dropping either field
stops compiling.

A host that omits the window is now refused. Only a host too old to advertise
`mobileWeb.bundle.v1` can send one, and the phone never asks such a host for a
manifest. The probe test's fake manifest gained the fields it was missing,
which is the typed reader catching its first stale fixture.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): say whether a bundle verdict actually checked a manifest

`ok` meant two different things: the manifest was read and its window contains
the host, or no manifest had been read at all. A caller that mounted on the
second would mount an unchecked bundle, so `manifestChecked` separates
permission to fetch from permission to open.

The host-status input is now a `Pick` of `HostStatusReply` instead of a
hand-copied pair. Both fields default through `?? 0`, so an upstream rename
would have silently blocked every host rather than failing a build.

Drops two assertions that restated the module's own literal back at it. What
proves today's bundle opens is that the shared contract's schema version is a
member of the supported list, so that is the assertion left standing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): offer a refetch, not a store, for a bundle the host outgrew

`bundle-incompatible` on the mobile side means the workspace cached for this
host is older than the host's client floor. A store update cannot clear that
and a reconnect can, so the screen no longer sends the user to a download that
would change nothing. The button is gone rather than relabelled, because the
recovery is leaving this screen, and the note drops its "already updated?"
opener for the same reason.

`blockRemedy` replaces `updateSide` and is now passed to the copy instead of
recomputed there, so the title, the body, and the button are decided once.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the platform assertion from the block-screen mock

The mocked `Platform.OS` was widened with an assertion so a test could switch
stores. An annotation on the binding does the same widening in a position the
compiler checks, which is what the changed-code quality gate asks for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say which bundle-compat default is fail-open, and drop a dead field

Both comments claimed the two host-status defaults point the same way. Only
`protocolVersion` is absent-means-oldest. An absent `minCompatibleMobileVersion`
is `?? 0`, which is no floor at all, so the mobile arm is fail-open by design
and matches `evaluateCompat`. A reader taking the old sentence at face value
would have gone looking for a bug.

`supportedSchemaVersions` had no consumer on the verdict: the block screen
renders a title and body, and B4 reads neither. The exported constant stays,
since that is what the wall is decided against.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 04:27:45 -04:00