Commit Graph
46 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 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
Jinwoo Hong 40b2230508 test(mobile): typecheck the test files on a ratchet, and pin the reply enums where tsc looks (#21298)
* fix(mobile): move the last six reply-enum pins where tsc looks

mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>`
coverage record in a schema test is never typechecked: the two that existed
(SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four
closed enums beside them had only a doc citation of the host type.

Each arm list moves into its schema module as hostUnionArms<Union>(), which
#21269 introduced for the same reason, and each test iterates the exported
list instead of holding its own copy:

- SSH_CONNECTION_STATUS to SshConnectionStatus
- PROJECT_OWNER_TYPE to GitHubProjectOwnerType
- DETAIL_FILE_STATUS to GitHubPRFile['status']
- PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal
  arms of MobilePushTestResult and MobilePushRegisterResult
- SETUP_RUN_POLICIES to SetupRunPolicy

openEnum's parameter widens from a non-empty tuple to `readonly string[]` so
a hostUnionArms list can feed it. z.enum already accepts the same, so the
tuple constraint only excluded callers zod itself takes; behaviour unchanged.

Twelve mutations prove the pins: dropping one arm and adding a bogus one
each fail mobile tsc in all six places. Zero goldens move, the schemas'
behaviour being unchanged, and the 21 recording suites pass at the existing
baseline.

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

* test(mobile): fix the type errors in eighteen test files

Found by typechecking the tests for the first time (see the config that
follows). All mechanical, none weakens a product type:

- 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils
  where act wants void, so each becomes a block. The async ones await only a
  genuinely promise-returning call, so no extra microtask tick is introduced.
- Four fixtures were stale against a product type that gained a required
  member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError,
  the branch-compare summary's errorMessage, and SessionOptionDescriptor's
  transport, which #20884 added precisely so a producer could not inherit the
  wrong lane's rendering by omission.
- `getLastConnectedAt` on the shared relay fake was typed `() => null`, which
  refused the timestamp two escalation suites assign to it.
- Two holders used before assignment take `!`, one `advance!.kind === ...`
  becomes `advance?.kind`, one widened status arm takes `as const`, and the
  Expo notification fixture keeps `data` required because the dismissal cases
  assign through it.

631 test files pass, 6222 tests, unchanged.

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

* test(mobile): typecheck the test files, on a ratchet

mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the
release bundle, and vitest transpiles without checking types. Nothing had ever
typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written
in one proved nothing and why 144 of the 630 test files had drifted.

tsconfig.test.json is that program with the tests put back, behind
`typecheck:tests`. Four files stay out: they import the desktop main process or
src/shared/child-process, which are written against @types/node, and this
program's libs are React Native's, where setTimeout answers a number rather
than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the
desktop rather than about mobile; vitest runs those four under Node, which is
where they belong.

The CI gate is a ratchet rather than the raw typecheck, modelled on
check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that
set and fails when a file that checks today stops checking, or when a baseline
entry starts checking and was not pruned. The list may only shrink.

Why not zero: 180 of the remaining 510 errors are one seam — tests locate
mocked react-native components by string name, which `ElementType` does not
admit — and closing it means either 180 casts or a global JSX declaration for
the mocked names. That is a design decision, not a mechanical fix, so it is
left for a follow-up rather than made here. The rest are smaller clusters of
the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing,
and createElement props fixtures.

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

* docs(mobile-recorder): correct the corpus counts and the salvage claim

The oracle section still quoted the corpus as 368 scenarios and 727 goldens;
it is 393 and 778, and the three replay suites report 781 tests. Each number
now names the command that measures it.

"No golden carries one" was the load-bearing error: 44 goldens carry a
recorded `reply-salvage` today, starting with the push-test unknown-reason
scenario #21176 added for exactly that purpose. The paragraph claimed the
observation pins an absence when on those families it pins a recorded drop.

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

* test(mobile): pin the tests-typecheck ratchet's parser

The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail
under an error. Counting those as filenames would write unparseable entries
into the baseline and leave the gate unprunable, so the parser is pinned on
that shape as well as on the added/stale diff.

Written against the gate itself: it flagged this file before the directive it
carried was removed, which is the end-to-end proof the spawn half works.

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

* test(mobile): await the timer advances the act() rewrite dropped

Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a
braced body left the returned promise floating at 27 sites, so the advance
was no longer ordered before the assertions that follow it.

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

* test(mobile): unshadow MobileHostCard's .tsx suite

A wildcard `include` keeps only the higher-priority extension, so
MobileHostCard.test.tsx sat outside every tsc program while
MobileHostCard.test.ts existed beside it. Its one error is the same
react-test-renderer seam its sibling is baselined for.

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

* test(mobile): census every test file into the typecheck program

The ratchet diffs only files that error, so a test excluded from
tsconfig.test.json or shadowed by a sibling extension left the gate
silently. Every *.test.ts(x) on disk must now be in the program or
named in TESTS_OUTSIDE_PROGRAM with its reason.

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

* fix(shared): make the enum helpers refuse the ways they can prove nothing

openEnum takes a `const` T so a bare literal keeps its arms rather than
widening to string. hostUnionArms blocks inference of U with NoInfer and
defaults it to never, so a call that omits the host union — where the
record would only pin itself — no longer compiles.

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

* docs(mobile): describe the census and correct the baseline count

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

* test(mobile): give the push fixture cast its SAFETY rationale

Widening the pre-existing cast made the changed-code gate attribute it as
a new finding.

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

* test(mobile): build the push fixtures as typed notifications

Replaces the `as unknown as` cast with Expo's own types, filling
FirebaseRemoteMessage and its notification once in two builders, and
passes the data payload in rather than mutating through an optional
member. Typing the fixture showed one assertion comparing the scheduled
content against the whole arriving content, which only held while the
cast let the fixture omit the two members the presenter drops; it now
names the four members the presenter forwards.

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

* test(mobile): keep the grouped-question advance read non-optional

`advance?.kind` let an absent advance take the null-draft branch instead
of failing.

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

* fix(mobile): run the tests-typecheck ratchet on Windows

Spawns tsc's JS entry on this Node instead of the node_modules/.bin
shim, which is a POSIX shell script that Windows resolves to tsc.CMD and
then appends .exe to. Parsed paths are normalised to POSIX so a Windows
run does not read every baseline entry as both stale and added.

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

* test(mobile): close the ratchet's @ts-nocheck hole and read tsc once

tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed"
with one line, pruned, and never checked again; the census now names any
program test file whose leading comment carries the directive.

`--noEmit --listFiles` answers both questions in one pass, so the gate
spawns tsc once rather than twice. Corrects the two stale counts, and
states hostUnionArms' real reason for living in the schema module now
that tests are typechecked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 18:58:25 -04:00
Jinwoo Hong 4a86b2dc56 refactor(mobile): checked reply readers for files, dictation, host-screen and agent-history (step 7) (#21269)
* test(mobile): record main's file-preview and markdown-disk-fallback replies

Four of this branch's read sites had no malformed-reply coverage, so the reader
change would have had nothing to move at them. `familyGoldens` matrixes only the
first scenario of each family, and `files.preview-load`'s base is the grant-refresh
chain while `session.tab-documents`' is the served markdown tab — which left
`files.read` and `files.readPreview` on the worktree preview path, the artifact
image read, and the markdown tab's on-disk fallback recorded on their success path
only. This commit is the before picture, taken from main's own tree with no product
edit in it.

Three new families, five scenarios, ten goldens:

- `files.preview-worktree-text` / `files.preview-worktree-image` — `files.read` and
  `files.readPreview` as the preview screen asks them for a worktree file.
- `files.preview-artifact-image` — `files.readTerminalArtifactPreview`.
- `session.markdown-disk-fallback` — the `files.read` leg a headless host's
  `renderer_unavailable` sends the markdown tab down. It carries a second scenario
  that serves `markdown.readTab`, because a matrix site needs a fulfilled reply
  recorded somewhere in its own family to replay as the `normal` partition.

No existing scenario moved to a new family and no adapter changed, so every
pre-existing golden keeps its `adapterSha256` and `scenarioSha256`. Recorded in a
detached worktree at the manifest's pin (`4b876758d3`) with this manifest copied in;
the control is that all 748 pre-existing goldens came back byte-identical to
origin/main's, which `git diff c2962a765a -- mobile/rpc-foundation/goldens` confirms
as empty.

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

* refactor(mobile): checked reply readers for files, dictation, host-screen and agent-history

Thirty-five unchecked reply readers across seven files become checked zod readers,
so a malformed host reply surfaces as one readable error at the operation boundary
instead of a downstream TypeError, a rendered `undefined`, or a screen left ready
over garbage. Deliberately a behaviour change on malformed replies only: every good
reply decodes to the same value it did, which the `normal` partition of every
matrix golden holds byte for byte. Nothing on the wire moves — no method, params,
options, timeout or acceptance policy changes at any site.

The inventory drops from 137 readers over 31 files to 102 over 24.

What each domain checks, and what it deliberately does not:

- files/preview — one schema for `files.read` and `files.readTerminalArtifact`, one
  for the two preview methods. `content` is required on the text pair because the
  markdown disk fallback publishes it into the tab with no guard; the image pair
  requires nothing, because normalizeImagePreviewResult guards all four members and
  the host's own "binary I cannot preview" and "not actually an image" arms are good
  replies the screen renders today.
- files/tab-doc — stricter than the preview screen on the same two methods, because
  a tab publishes what it read into a typed ready document with no guard. `git.diff`
  reads as two variants, and an arm this build has not heard of takes the binary one
  rather than refusing the reply.
- files/explorer — the directory listing is an array and a row needs the name and the
  directory flag the tree projection turns on; the legacy capped list needs its rows'
  paths and the truncation flag its note draws.
- files/ownership — the two members that decide *where a write lands* are fatal on a
  wrong type rather than salvaged, because absence reads as `local` downstream and a
  salvage would send a mutation to the wrong host. `hostId`'s absent/null/string
  states stay distinct, and the SSH connection generation passes through at its own
  type because the mutation echoes it back to the host.
- dictation — the setup the sheet renders is checked; the model rows need the `id`
  the sheet keys and sends back. The five sends whose reply body no call site reads
  keep an unknown payload, and so does `speech.dictation.finish`, whose transcript is
  read past a staleness guard that a reader throw would move the failure across.
- host-screen — the repo catalog, the SSH labels and the host platform. The four
  writes read no reply body; `worktree.activate` stays opaque because the session
  route's second report site awaits it outside any catch.
- agent-history — the capability gate and both scan containers. The session rows stay
  unknown on purpose: `agent` is a vocabulary that grows with every agent CLI Orca
  learns to scan and that this client echoes back on resume, so narrowing it would
  refuse a newer host's reply or drop the very sessions it added.

Two shared readers were widened to take the strings the reply readers hand them —
`getRepoExecutionHostId` and `buildRepoHostIdByRepoId` — because both already answer
`local` for a host-id spelling they cannot parse, and closing that spelling in a
reply schema would refuse a newer host's own catalog.

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

* test(mobile): repin the corpus and re-record the checked reply readers

`baseline` moves to this branch's last fenced commit, which is what `--record`
refuses without: main's fenced tree drifted past the session domain's pin when
#21114 and the dependency bump landed, and the product edit in the commit before
this one moves it again.

Every body move is confined to a malformed partition of a family this branch
touched. No `normal` partition moved, which is the byte-for-byte control on good
replies, and no golden outside the seven files' families moved at all.

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

* fix(mobile): stop the dictation reader requiring a mode main rendered without

The setup sheet's `normal` partition refused after the reader landed, which is the
success control saying the schema was wrong rather than the fixture: `dictationMode`
was declared required because the one unguarded consumer pushes it into a
`useState<'toggle' | 'hold'>` and cannot invent a value, but main rendered a sheet
whose reply omitted it, and requiring a member no consumer crashes on is exactly the
version claim Rule 1 of the remote-wire contract warns about.

The member is salvaged now and keeps its open arm set, so an unknown mode still
degrades to `toggle` rather than to one that matches no segment. The native-chat
refresh spells that same `toggle` for an absent mode, which is the value its state
already started at, and the route parity pins are refreshed for the one literal and
the two callback bodies that moved.

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

* test(mobile): repin past the dictation fix and re-record

Second repin of the branch: the fix to the setup reader is a fenced-tree change, so
`--record` refuses until `baseline` names it. The speech family's `normal` partition
is back to main's projection, which is what said the first reader was wrong.

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

* test(mobile): mutant evidence for the checked reply readers

Three mutations applied by hand, run, and reverted, recorded beside the adapter
family mutations in the same shape. They are kept in their own file because a reader
mutation is not killed by a pilot scenario: a pilot serves a good reply, and a schema
that has stopped checking a member reads a good reply exactly as before. What kills
them is a matrix golden's malformed partition, the schema's unit pin, or a consumer
pin, and each is named against its mutation.

Two survived their first run, and both survivals were defects in the gates:

- Loosening the file tab's `content` was invisible, because the pin dropped members
  only in pairs and each pair is refused by the sibling. The pin now drops exactly
  one member per iteration, and the preview text schema and the legacy file list got
  the same treatment.
- Collapsing the hostId tri-state was invisible, because no golden serves an explicit
  null host — the local ownership scenario omits the member. The ownership test now
  captures all three states end to end, which is where a tri-state belongs.

`repo-metadata-platform` is re-anchored where this branch moved the read it mutates:
the hand-rolled `readHostPlatform` became the reply schema's own projection. The
defect it injects is unchanged.

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

* test(mobile): record main's repo-icon and speech-vocabulary replies

The closed enums this branch introduced had no fixture behind them. `provider`,
`dictationMode` and `repoIcon` were carried by no scenario at all — the fulfilled
repo-metadata golden records `repoIconsByName: []` — so the corpus could not have
moved whatever arm set the schemas declared, which is how a reader can pin a
vocabulary the host does not speak and still decode to a zero-move delta.

Two scenarios, both appended to an existing family so `familyGoldens` adds no
matrix golden, recorded from main's own tree at the pin with no product edit in it:

- `settings-repo-metadata-icons` — all three `RepoIcon` arms, a github-sourced
  image with a label, an explicit `badgeColor`, and a mixed-host catalog so the
  ssh/settings/platform wave runs too.
- `speech-setup-sheet-model-vocabulary` — `provider` on both arms, `status` on two,
  `dictationMode: "hold"`, and null and numeric `sizeBytes`/`progress`.

Control: re-recording the whole corpus at the pin reproduces every committed
golden body, including this branch's five earlier before-pictures; only `baseline`
and the masked `lockfileSha256` move.

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

* fix(mobile): stop the repo icon narrowing a member no consumer reads

The image arm of `repoIcon` declared `source` as the four values
`RepoIconImageSource` spells today (src/shared/repo-icon.ts:3). MobileRepoIcon
reads `type`, `src`, `label`, `emoji` and `name`, and never `source`, so the only
thing that enum could do was fail the union arm for a source a later host adds —
dropping the whole icon and drawing the Folder default where main drew the image.
That is the one arm set on this branch whose degrade was not already main's own
behaviour for an unknown value.

Dropping the declaration keeps the member: `looseObject` passes it through
verbatim, so the decoded object is byte-identical to the one main published, which
`settings-repo-metadata-icons` now records.

The two type sites that hold an icon move to the decoded type. A host `RepoIcon`
still satisfies the rendered union, so the worktree rows that carry one are
unaffected.

Every other closed enum on this branch was checked against the host's own shared
type and left alone: speech `provider`/`status`/`dictationMode`
(runtime-worktree-contracts.ts:83/85/86), `groupBy`/`sortBy`
(persisted-ui-state-types.ts:41-42), `platform` (Node's own domain; the handler
answers `process.platform`). For each, a salvaged member lands on the same branch
main's unknown value did: `=== 'openai'` and `=== 'ready'` stay false, a missing
`groupBy` and an unmapped one both answer null, and an unknown platform and a null
one both label the host "This computer".

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

* test(mobile): repin past the repo-icon fix and re-record

Header-only: all 770 goldens move on `baseline` alone, including the two recorded
from main's tree two commits back. The icon fix and the two new fixtures decode to
the bytes main published.

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

* fix(mobile): keep the repo-metadata readers total the way main's were

readSshTargets and readHostPlatform answered [] and null for any payload at
all. The checked schemas threw for a non-object, and because the label write
runs first in the same sequence that throw also skipped the platform write, so
a malformed reply left both decorative labels at their previous values instead
of degrading. A .catch on each restores main's answer without giving up the
row filter or the checked reader.

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

* fix(mobile): forward the dictation mode instead of substituting a default

The reader closed the mode to two arms and the native-chat refresh spelled
`?? 'toggle'`, which is a good-reply change no golden covers: main left the
state undefined for a reply that omits the mode, and undefined binds no press
handler on the terminal input mic. Head gave that mic a working toggle. The
member is forwarded as the string the host sent and the refresh is main's line
again, so an absent or unknown mode leaves the mic exactly as inert as main's.
The route-parity runtime-string pin is main's own sha again.

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

* test(mobile): repin past the review fixes and re-record

The repo-metadata readers are total again, so both families' `result-absent`
and `result-null` checkpoints decode to main's bytes instead of the caught
throw, and the two delta rows they cost go away. The dictation mode forwards
verbatim, which no recorded reply exercises differently.

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

* test(mobile): repin onto the merge and re-record

Pins the corpus to the merge commit so main's ten create-terminal goldens and
this branch's own are recorded from one tree.

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

* docs(mobile): correct three reader comments round 2 caught

The ownership schema said an explicit null hostId means the host said local;
the code refuses it, which is the whole reason mutant (c) exists. The AiVault
sessions cast cited a golden whose fixture row carries three members, not the
sixteen the cast claims — the full row is in aivault-history-screen-listed —
and both the issues cast and the schema doc said the rows are rendered when
the only read anywhere is issues.length.

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

* docs(mobile): correct stale file:line citations in the batch-A reply schemas

Resolved every citation in the seven reply-schema modules and the SAFETY
notes against the tree and diffed each target line against the claim beside
it. Twelve were wrong, two of them past the end of a file that had shrunk,
so they read as evidence while pointing at a closing brace.

- file-explorer: the entries put is :157 not :160, the relativePath split is
  file-list-fallback.ts:48 not :42, and the truncated publish is :136 not
  :141. buildFileExplorerRows is no symbol at all; the sort-and-walk is
  flattenDirectoryCache (file-tree.ts:58).
- file-ownership: the !summary throw is :68 not :64.
- file-preview: the markdown disk fallback reads content at :60 not :65.
- file-tab-doc: the html body render is :68 not :81 and the file arm is
  :73-75 not :86-88 (the file has 78 lines); the isImage guard is :58 not
  :66; the kind !== 'text' branch is :41 not :44; mobileDiffImageDataUri
  spans :22-33 not :20-31; the unguarded content.length is
  mobile-diff-lines.ts:35, the function that does it rather than :34.
- agent-history: both members land at :133-135; :135 alone is issues.
- dictation: the parenthetical read as citing the staleness guard when it
  named the rpcPayloadMember read. Both are cited now, :237 and :225.

Comments only. No schema, type, or runtime behaviour changes.

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

* docs(mobile): name the unguarded activation report site that pins the opaque schema

Handler audit over all 33 interpret sites in the four domains found one site
that is structurally unguarded: use-mobile-session-startup.ts:170 reports the
activation verdict from inside a fire-and-forget `void (async …)()` whose only
`.catch` sits on the request, not on the chain. A throw there would be an
unhandled rejection and would also skip the terminal fetch below it.

Nothing throws there today, because `worktree.activate` reads
hostScreenUnreadReplySchema, which is `z.unknown()`. That totality is load
bearing rather than incidental, so the doc now names the line it protects and
contrasts it with the first report site at :141, which is chained
`.then(…).catch(…)` and would survive a throw.

Comments only.

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

* test(mobile): pin that a bound descriptor's interpret survives being detached

bindDeferredRpcOperation builds interpret as a shorthand method closing over the
captured operation, never `this`, which is what lets eleven call sites pass it as
a bare function reference. Nothing named that invariant.

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

* chore(mobile): repin the RPC recording baseline to the main merge

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

* fix(mobile): pin the closed reply enums to the host unions where tsc looks

pullfrog: the PR body promised a Record<HostUnion, true> pin for every
closed enum in this batch and the code had none. Adding them in the
schema tests would have changed nothing: mobile/tsconfig.json excludes
*.test.ts, so a coverage record there is never typechecked (a mutation
that dropped a key stayed green).

hostUnionArms(coverage) in zod-salvage spells the arm list as a
Readonly<Record<U, true>> in the schema module itself, called with the
host union as the explicit type argument: an arm the host adds is a
missing property, one it drops is an excess property. Used for the speech
provider and status (RuntimeSpeechModelSummary), the workspace groupBy and
sortBy (PersistedUIState) and Node's platform list, which host-screen now
imports from mobile-runtime-host-platform instead of duplicating. The repo
icon branches satisfy Readonly<Record<RepoIcon['type'], z.ZodType>>.
Three mutations (drop `manual`, add `bogus`, drop the image branch) each
fail tsc. The tests iterate the exported lists; the platform mutant is
re-anchored to the renamed constant.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 16:38:16 -04:00
Jinwoo Hong 3e32b83522 refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7) (#21176)
* refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7)

Twenty-one unchecked reply readers across thirteen files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`, or
a card left "proven" over a reply that carried no rows. Deliberately a behaviour
change on malformed replies only.

What each domain required, and why it required no more:

- notifications (5 readers). All four call sites read the payload through `?.`,
  so every schema is nullish at the top level and no member is required. The
  test-push `reason` and the register `reason` become closed enums, because the
  two comparisons against them are the whole of what they decide and an arm this
  build does not know took the generic copy on main too. The stream unsubscribe
  and the unregister read no body at all.

- components (4). `repo.hooks` requires `source` and nothing else: the drawer
  assigns it straight into `SetupHookDetails.source`, whose type is
  `string | null`, with no guard in between — nullable so the "no hooks file"
  answer keeps its explicit null. `setupTrust` is nullable as well as optional
  because the `components-setup-ask` fixture sends an explicit null, and
  salvaging that would move a `normal` golden. `ui.get`'s trust record salvages
  per repo, so one unreadable repo cannot cost the others their approvals. The
  Codex redeem reply stays `z.unknown()`: `decodeResetResult` is a real
  scope-and-snapshot validator and splitting it would give one reply two refusal
  rules.

- terminal (4). The send verdict and the viewport pair keep main's exact
  `=== true` projections. `terminalSendAcceptedSchema` moves here from the
  session domain, which now re-exports it: terminal is the lower layer and two
  identical copies could drift on what "delivered" means.
  `terminal-send-rpc-response.ts` is deleted, its projection now being the
  schema's.

- transport (3). `status.get` declares its five members and requires the object;
  the three callers disagree about what an unreadable status means, so each keeps
  its own verdict behind a named reader — the gate wants the failure, and the
  probe and the pairing race must not have it, because both call `interpret`
  inside a `.then` fulfilment handler where a throw becomes a detached rejection.
  `capabilities` salvages whole rather than per element, which is main's own rule
  and what `transport-capability-probe-non-string-capabilities-drop` records.
  The two pairing readers are the shared credential contract itself, moved off
  the four call sites that each ran `.parse()` on the interpreted value; its
  `.strict()` is main's shipped rule for that released surface, not a new one.

- home (2), worktree (2), browser (1). The stats row is checked as an object and
  nothing more, `totalHomeStats` being the reader that says so itself; its
  per-host slot is now typed as the wire row it holds rather than as the computed
  total. `worktree.ps` cannot require `worktrees`: the host answers a union whose
  unchanged arm carries `{ unchanged, snapshotId }` and no rows. The twelve
  browser commands read no body; `browser.goto`'s settled URL stays nullish
  because `navigateToAddress` is inline in `MobileBrowserPane.tsx`, which no
  adapter mounts, and a move there would ship unevidenced.

Three fixtures were wrong and are corrected, each disclosed rather than worked
around: the runtime-context test kept a content hash directly under a repo key,
which is not a shape `ui.get` sends; and two snapshot-client tests ran their
reply list dry and handed `fetch` an absent result while claiming to model a
transport failure.

`push-test-envelope` is re-anchored at the same defect's new home, the cast
having been deleted. The boundary test's offender floor comes down from 20 to 10
with the list, which is what its own comment says it is for.

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

* test(mobile): repin the corpus and re-record step 7's checked reply readers

`baseline` moves to this branch's product commit, which is what `--record`
compares the fenced tree against, and every one of the 758 goldens is
re-recorded from it. The repin is what rewrites the `baseline` header on all of
them; nothing else about the corpus moves except the bodies disclosed below.

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

* test(mobile): mutate the workspace catalog's reader back to unchecked

The step-7 defect evidence needs a scenario whose reply is the one the change
moves. Every pilot scenario in the catalog family scripts a well-formed reply, so
a mutant that only changes how a *malformed* reply reads has nowhere to diverge —
which is why the pilot's own suite passed against an unchecked catalog reader
while its matrix golden failed.

`worktree-catalog-snapshot-unreadable` scripts `worktree.ps` answering
`{ ok: true }` with no result at all, which is what `result-absent` drives at the
matrix site, and records the fetch rejecting with `RpcIncompatibleReplyError`.
`worktree-catalog-unchecked-reader` then swaps the operation's reader for one that
answers `compatible: true` for every payload — main's reader, in one line — and
the recording moves back to a fulfilled fetch carrying
`admission: { kind: 'invalid' }`, which is the answer that let a broken catalog
render as an empty host (STA-3123).

One golden added and none moved: the manifest sits outside the fenced paths, the
family's matrix base is still `worktree-catalog-snapshot`, and the mutation
registry is not part of `recorderSha256`.

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

* test(mobile): pin the push-test reason arms the closed enum constrains

`pushDeliveryTestResultSchema.reason` closes over the four arms of the host's
`MobilePushTestResult` (src/shared/mobile-push-contract.ts:99), but no scenario
carried the member, so the corpus could not have caught a wrong vocabulary.
Three scenarios on the existing display-test mount carry it now: the two arms
the screen branches on and one arm no build knows.

Each golden was recorded first at the main pin 4b876758d3 and reproduces there
byte for byte, so the `normal` partition pins main's rendering rather than this
branch's. The unknown arm is the load-bearing one: main renders "Could not send
through Orca's push service." for an unrecognised string, and the salvage drop
renders the same sentence, so the closed enum costs a recorded `reply-salvage`
observation and no screen text.

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

* docs(mobile): cite the host union behind each closed reply enum

A closed `z.enum` is only as good as the vocabulary it was checked against, and
mobile's own declared types are written from memory. Each of the three enums now
names the host type it mirrors, so the next reader re-checks it in one grep
rather than trusting the arms.

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

* test(mobile): repin the corpus to the commit that carries the host-union citations

`--record` compares the fenced tree against `baseline`, so a comment in
`mobile/src` moves the pin like any other product edit. Every one of the 762
goldens changes by exactly its `baseline` line and nothing else, which is the
evidence that the citation commit is inert.

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

* fix(mobile): keep the agent-history panel rendering when the catalog omits its rows

`worktrees` is a salvaged member, so it is absent on the three envelopes that
read as an object without rows, and `use-mobile-agent-history-state.ts:61` calls
`.find` on it with no guard. The cast erased that and the corpus recorded the
result: `matrix-aivault.history-screen-worktree.ps-1` carried
`crash: Cannot read properties of undefined (reading 'find')` on
`inner-ok-missing`, `inner-false-string-error` and `inner-false-object-error`.

`?? []` is what the sibling Home card already does. The SAFETY note cited that
card's golden, which is the opposite site, and now cites this panel's own family.

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

* fix(mobile): keep a malformed worktree.ps reply reported as invalid_response

`host-workspace-list-states.tsx` prints this code to the user verbatim, so the
checked reader's throw landing in the generic catch renamed a host-payload
defect into a connectivity failure. STA-3123 exists to make a broken remote host
diagnosable, which `network_error` is not.

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

* refactor(mobile): give the browser page commands back their one-line form

Only `browser.goto` reads a reply body, so the reader belongs on a two-argument
wrapper rather than on all thirteen call sites. The exported type of every
command is unchanged, and the doc comment no longer promises a shape the file
did not have. 109 lines to 72.

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

* refactor(mobile): import the terminal send verdict from the terminal domain

The re-export forwarded to two callers, which a direct import already gives
them, and the session suite re-pinned three cases `terminal-reply-schema.test.ts`
owns. One definition, one pin, one file hop fewer.

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

* test(mobile): move the codex reset-credit pins next to their schema

They sat in the New Workspace suite, so a reader looking for the capability
whole-list drop by filename did not find it.

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

* test(mobile): repin and re-record after the round-1 fixes

Three checkpoints of `matrix-aivault.history-screen-worktree.ps-1` move from a
recorded white screen to the loading list: `inner-ok-missing`,
`inner-false-string-error` and `inner-false-object-error` answer the panel an
object with no rows, and the `?? []` now seats an empty list instead of letting
`.find` throw. Every other golden changes by its `baseline` line alone, which is
the evidence the other four fixes move nothing the recorder observes.

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

* refactor(mobile): stop requiring the stats row its own reader guards

Round-2 findings 1 to 3.

`homeHostStatsSchema` required an object that `totalHomeStats` already guards
(`if (!host || typeof host !== 'object') continue`), so the requirement bought
nothing at the read and cost the row upstream: the refusal reached
`fetchMobileHomeStats`'s `.catch`, the per-host slot was never written,
`hostIds.filter` found no host and the Home header drew no stats row where main
drew `0 / 0s / 0`. It takes `.nullish()`, and `HomeStatsRow` admits the
`null | undefined` main always had. The unit pin now says the slot keeps a null
summary and the total skips it, and sums one through `totalHomeStats` to show the
zeroed row survives.

The Home card's `SAFETY:` note claimed the reader proves `worktrees` is an array.
It does not; the `?? []` does. That is the same false sentence round 1 removed
from the agent-history panel, and a reader who believed it would delete the `??`
and reintroduce the white screen.

The `catalogError` branch on `RpcIncompatibleReplyError` had nothing holding it:
no adapter mounts the host screen, so no golden can reach it. One case in the
snapshot client pins the class the `catch` keys on. Mutation-checked by forwarding
the catalog schema as `z.unknown()`, which fails that case alone.

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

* test(mobile): repin and re-record after the round-2 fixes and main's merge

Pin moves to 0b8bd1c3c7, this branch's last fenced commit. The re-record
normalises the ten session create-terminal goldens main re-recorded in #20069
without repinning, and writes the Home stats family's bodies back to what main
records now that the summary reader no longer requires the object its own
consumer guards.

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

* test(mobile): repin and re-record the corpus at the merge with main

The merge took main's goldens verbatim at main's pin, so the four goldens this
branch adds were the only ones whose header did not name a commit in this
history. Repinning to the merge commit and re-recording gives all 764 one pin
and one recorder, which is what the new ancestry guard asks of the corpus.

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

* docs(mobile): point the reply schemas' citations at the lines they claim

Every `file:line` in this branch's diff was resolved against the tree and its
target line compared to the claim beside it. Fourteen were wrong. Most had
drifted one to four lines onto a closing brace or the line after the code they
quote, which reads exactly like a good citation and can only be caught by
resolving it.

Two were wrong in kind rather than by a few lines. The viewport comment
credited the `=== true` projection to the refit call site, which reads plain
truthiness; the rule was main's reader, and the refit's two decisions are the
lines now cited. The capability comment cited a line for main's
`every(typeof === 'string')` rule inside the code this change deletes, so it
resolved to an unrelated brace; it now states the behaviour and says why no
line carries it.

Two more pointed at the head or tail of the statement they named and are
tightened to the line that does the work.

Comment-only: no schema, no reader and no call site moves. The corpus is
repinned and re-recorded on top because the recorder fences `mobile/src`.

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

* test(mobile): repin and re-record after the citation fixes

The citation commit is comment-only, and the corpus says so: all 764 goldens
move on the `baseline` header line and nothing else. The re-record is needed
only because the recorder fences `mobile/src`, which a comment is inside.

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

* fix(mobile): keep the three unguarded-site reply schemas total so a malformed result cannot throw where main tolerated it

The ui.get reader is interpreted inside a fire-and-forget IIFE with no catch, and the home
stats and push delivery readers sit behind handlers that would print the reader's own sentence
where main drew a zeroed row or the generic copy. Each schema now decodes any unreadable
result as absent, which lands in the fallback main already took.

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

* test(mobile): repin and re-record the corpus over the total schemas

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

* docs(mobile): name the host-status gate reader by its export

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 12:40:18 -04:00
Jinwoo Hong b8d4cde09f refactor(mobile): send six screen-mounted call sites through typed RpcOperations (step 4, wave 3) (#20919)
* test(mobile): record six screen-mounted call sites before migrating them

Five new mount adapters and six scenarios, recorded against the pinned baseline's
product code so the goldens are main's behaviour, not the refactor's.

Each site is a screen the recorder could not previously mount:

- `home.host-accounts` mounts `fetchMobileHomeAccounts`, whose decoder is
  re-exported through `AccountUsage.tsx`. That module loads under the mount
  loader, so the inventory's "no recording can load it" was already stale.
- `notifications.display-test-screen` mounts the settings push probe and presses
  its button by reading the handler back off the rendered inert `Pressable`.
- `aiVault.history-screen` mounts the history panel, which is where the last
  `worktree.ps` lives. Split in two: the base stops once the worktree list has
  seeded the scopes, because a reply partition there changes the scopePaths the
  downstream `aiVault.listSessions` carries, and a matrix variant cannot assert
  params it moved. The full chain is a second scenario, driven as a pilot only.
- `tasks.route-repo-list` mounts the tasks screen-root hook and calls its own
  `ensureLoaded`, which is the only thing that fires `repo.list`.
- `linear.select-workspace-picker` calls the render helper the tasks surface
  calls and invokes the `onSelect` on the element it returns. The picker draws
  inside `BottomDrawer`, whose reanimated timing driver and gesture builder the
  recorder would have to impersonate for a row to exist; the closure is the same
  either way, and the workspace a selection carries comes from the scenario.

Five substitute members are added, each with the recording that reads it:
`react-native-safe-area-context.useSafeAreaInsets` and
`expo-router.useLocalSearchParams` for `tasks.route-repo-list`, and
`react-native.TextInput`, `.SectionList` and `.RefreshControl` for
`aiVault.history-screen` once its list renders. `useLocalSearchParams` answers one
pinned route for the same reason the window size is pinned: a screen's own address
is not a device reading, and the one screen that reads it sends `repo.list`, which
takes no params.

Touching the substitute table moves `recorderSha256`, so all 641 existing goldens
are re-recorded. Recorded from a detached worktree at the pinned baseline with this
branch's recorder laid over it: every pre-existing golden is header-only, verified
by resolving both sides through the value pool — 641 header-only, 0 body, 0 deleted,
one distinct `recorderSha256`, `baseline` and `lockfileSha256` across all of them.

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

* test(mobile): type the linear workspace picker's model fixture

`mobile/tsconfig.json` covers the recorder, and the fixture's setters were written
with the argument the product happens to pass rather than the `SetStateAction` the
model declares. Typing them moves `adapterSha256` on the two goldens recorded through
this module, so they are re-recorded here rather than in the refactor commit, which
must move none.

Re-recorded at the pinned baseline: `linear-select-workspace` and its reply matrix,
header-only, bodies unchanged.

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

* refactor(mobile): send six screen-mounted call sites through typed RpcOperations

Nine references off the raw request port, across six files. Every one is proven
against the goldens recorded in the previous commit from the pinned baseline's
product code: this commit moves no file under mobile/rpc-foundation/goldens.

Reused rather than redefined:

- `worktree.ps` in the history panel sends through `worktreeCatalogRead`. Same
  question, same acceptance — a refused list leaves the screen on what it holds.
- `repo.list` in the tasks screen-root hook sends through `newTabRepoListRead`.
  Its policy raises the host's message and its reader takes `repos` off the payload
  while preserving the property-read exception a null result used to throw at the
  cast, which is what this call site did by hand. Its name still says new-tab; a
  third consumer does not make renaming it this bucket's business.

Four operations are new, each because no existing reader on the method takes this
consumer's input:

- `files.read-directory-or-skip` and `files.legacy-explorer-list-or-skip` for the
  explorer. Both skip, because neither refusal is the operation's to decide: the
  readDir refusal code selects the legacy fallback and the list refusal supplies the
  message. The existing `files.list-or-skip` reads the `files` member alone, and the
  explorer also needs `truncated` for the "Showing first 5000" note.
- `accounts.home-snapshot-or-skip` for the Home card, decoded by
  `decodeAccountsSnapshot` at the call site as before.
- `notifications.test-push-or-skip` for the settings probe, whose `forbidden` and
  `method_not_found` refusals mean "try the next desktop".
- `linear.select-workspace-or-skip` for the filter sheet.

Two behaviours are preserved rather than repaired, both recorded:

- The workspace switch never read its reply. `.then(() => loadLinearContext())` runs
  on a refusal exactly as on a success, so only a transport rejection reaches the
  error copy. Interpreting the operation here would surface a refused switch for the
  first time; that is a product change with its own re-record.
- `app/terminal-settings.tsx` still reads `ms` off the reply envelope instead of off
  its result, so the value is always undefined. It did not migrate, and the inventory
  now carries the defect as its own note.

Four mutants are added, one per new family that admits a state-only one:
the Home snapshot, the push test result and the tasks repo list each decoded one
level above the envelope, and the workspace switch with its context reload dropped.
`aiVault.history-screen` gets none and says why in the suite: everything
`worktree.ps` publishes also moves the `scopePaths` the next scripted completion
asserts, so a mutant aborts the sequence instead of diverging from it. Its evidence
is the reply matrix at that request.

The tasks source-parity ratchet moves with the family it guards: hook, statement,
declaration, render and style counts are unchanged, and the semantic source is a pure
deletion of four lines — two `rpc:` call signatures and the two method literals they
carried.

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

* test(mobile): matrix the six new screen families' replies

One golden per scripted reply, eleven partitions each, recorded at the pinned
baseline alongside the pilots. Seven sites: `accounts.list`, `notifications.testPush`,
`repo.list`, `linear.selectWorkspace`, and all three of the history screen's —
`worktree.ps` and the two `status.get` reads its scan chains off the worktree list.

The history matrix is also that family's defect evidence in place of a mutant: every
partition at `worktree.ps` changes the `scopePaths` the downstream `aiVault.listSessions`
carries, and the sender args are recorded with it.

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

* docs(mobile): correct three operation and mutant comments

Comment-only, no product behaviour and no golden movement.

- `worktreeCatalogRead` says two readers; there are three. Names the third
  (the agent-history panel's `scopePaths` seed) and drops the stale count from
  the module header, which described call sites rather than the two operations.
- `newTabRepoListRead`'s census counted the two operations over `repo.list`, not
  its own two callers, and claimed both read a workspace's connection id. The
  tasks route keeps the whole list for its repo pickers. The split from
  `nativeChatRepoListRead` stays where it belongs: acceptance.
- The `aiVault.history-screen` mutant note pointed at the reply matrix as the
  accepted-vs-refused oracle. Decoding
  `matrix-aivault.history-screen-worktree.ps-1.json` through the value pool
  shows `normal`'s projected state is identical to all seven non-crashing
  partitions (spinner, two labels, zero rows). The real oracles are the next
  request's `scopePaths` (`["/repo/feature"]` vs `[]`) and the crash channel the
  three `inner-*` partitions land in.

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

* docs(mobile): give the second files.list reader its real reason

Comment-only, no product behaviour and no golden movement.

`legacyFileListRead` claimed "the member reader rejects this consumer's
input". Nothing rejects: `rpcUncheckedMemberReader` returns the member,
and reusing it here would simply drop `truncated`. The reason the explorer
declares its own operation is the other direction. Widening
`files.list-or-skip` to a payload reader would split the `workspace-files`
variant it shares with `nativeChatFileSearchRead` over
`files.searchPaths`, whose only caller feeds both through one
`extractPaths` in `use-mobile-native-chat-file-search.ts`, so the member
read would move into that hook rather than disappear.

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

* style(mobile): indent the six scenario entries spliced during the merge

The conflict on `pilot-scenarios.json` was resolved by id rather than by
hunk, splicing this branch's six entries into main's text at the array's
close. The splice started at the entry's `{` instead of at its line, so
those six lines lost their indentation. oxfmt's only change is those six
lines; the parsed document is identical, and the recording suite still
matches all 667 goldens, so no scenario digest depends on the raw text.

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

* test(mobile): re-record the merged goldens once at the pin

One record for the whole merged tree, at the unchanged baseline
e7206f62a8, through a detached worktree reset
to that pin with this branch's rpc-recording tree, scenarios and recorder
script overlaid. Product source in that worktree was proven identical to the
baseline before the run, so the recordings describe the pre-refactor product.

13 goldens move, all of them the ones #20915 added. They arrived carrying the
recorder digest from before this branch edited `screen-native-substitutes.ts`,
and `recorderSha256` is the only key that moves on any of them; every
recording body is identical after decoding through the value pool. The other
654 were re-recorded byte-for-byte and are not in this commit.

All 667 goldens now carry one `recorderSha256`, one `baseline` and one
`lockfileSha256`.

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

* docs(mobile): state the real gates on two screen holdouts

Comment-only, no product behaviour and no golden movement.

The accounts route said "the screen now mounts". It does not, at this
commit: it reads `expo-router.useFocusEffect` and `react-native.ScrollView`,
neither is a substituted member, and the trap refuses before any effect
runs. The note now names that as the first gate and the `accounts.subscribe`
effect as the second, and says why the two members are not added here.

The host-screen overlay note blamed a "reanimated timing driver" for
deciding when the drawer's children exist. Nothing gates them:
`resolveBottomDrawerMounted` returns `visible || mounted`, `BottomDrawer`
renders `MountedBottomDrawer` on that, and that component renders its
children unconditionally inside its `Modal`. The blocker is the module's
own imports of reanimated and gesture-handler, neither substituted.

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

* test(mobile): drop the tasks route adapter's unreachable reload action

No scenario names `reload-repos`, and no schedule driver can generate it:
the drivers emit only disconnect, cutover, reset, unmount, blur and remount.
Every other action on this adapter is reached by a scenario. Deleting the
branch leaves the remount and unmount branches, which are driven.

Re-recorded once at the pin e7206f62a8 with
the product source in that worktree proven identical to the baseline first.
Two goldens move, both in the `tasks.route-repo-list` family, with
`adapterSha256` the only moved key and both recording bodies identical after
decoding through the value pool. The other 665 re-recorded byte-for-byte.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 22:10:22 -04:00
Jinwoo Hong 36ef93a64f refactor(mobile): migrate the small domains onto RpcOperation (step 4) (#20705)
* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's

`recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR
that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its
merge with main conflicted on that one line in 153 files; every future domain PR would collide
with every other in flight the same way.

Split the directory at a real seam instead of a filename convention: `adapters/` holds one module
per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers
the engine only. A new `adapterSha256` covers the source of the module that mounts each operation
a golden's scenarios drive, read off the same `mounts` calls that build the table the recording
runs against, so the pin cannot name a file the runner did not use.

Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the
goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file
inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file,
and an adapter importing a sibling each fail.

The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which
leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new
header field; the goldens re-record in the next commit.

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

* test(mobile): re-record the RPC goldens under the split recorder/adapter digest

Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers
`adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and
recording ran against the same pinned product tree.

    git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
      | grep -vE '^(\+\+\+|---)' \
      | grep -vE '^[+-]  "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l
    0

The seven `adapterSha256` values partition the 153 goldens by the module each was recorded
through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory,
9 tasks, 8 workspace settings.

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

* refactor(mobile): stop pinning goldens to recorder inputs no recording can read

The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the
per-family mutant registry beside it, and the probe-hole witness. None can change a recording --
the loader consults a mutant only when a mutant test asks for one, and no suite but the two
recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have
and charged every domain a full re-record for it.

`mutants/` now holds the table, the registry, the reference states, the mutant suites and the
probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can
reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by
name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts`
checks exactly that, and fails if an engine file names the directory or anything outside imports
from it.

`recorderSha256` also pins only the suites in `recording-drivers.ts`, which
`scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or
writes one to a scratch directory, is no longer provenance for a recorded file.

`OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold
the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own
exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden.

Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to
`root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed
anything. Each call now spells the root differently.

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

* test(mobile): re-record the RPC goldens under the mutant and driver exclusions

Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153
because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that
module now carries its own exposure declaration.

    git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
      | grep -vE '^(\+\+\+|---)' \
      | grep -vE '^[+-]  "(recorderSha256|adapterSha256)":' | wc -l
    0

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

* fix(mobile): restore the preferences actions the merge resolution dropped

#20568 added `resume` and `trust` actions to the `settings.task-preferences`
adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already
moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the
`pilot-mount-adapters.ts` conflict in favour of the registry merge silently
discarded them and `tw-task-preferences-resume-write` failed to record at all
("Missing or completed request: ui.set#1").

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

* test(mobile): re-record the RPC goldens at main's tip after the merge

All 208 goldens, header-only. `baseline` moves from 50e752fc66 to main's tip
c6a7216984, `goldenFormatVersion` from 4 to 5, `recorderSha256` to the value of
the engine with `adapters/` and `mutants/` carved out, and `adapterSha256` is new
on every file. Nine distinct adapter digests over 208 goldens: each golden now
pins only the module that mounts it.

No observation moved. The whole-diff census against origin/main reports exactly
four changed keys and nothing else:

  208 "adapterSha256":   416 "baseline":
  416 "goldenFormatVersion":   416 "recorderSha256":

Recorded in place rather than through the README's detached-baseline dance: this
branch changes no product file, so its tree at the merge is byte-identical to
c6a7216984 under mobile/src, src/shared and the lockfile, and the parity claim
stays non-circular. README says so now.

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

* test(mobile): hold the recording drivers to the engine's mutant-seam rule

The name scan exempted every `.test.ts` on the ground that a test cannot change a
recording. Two of them can: the recording drivers are the recording path. A driver
that read the mutant table by path rather than importing it passed both seam checks
— the import scan sees no import, and the name scan waved it through as a test:

  const table = resolve(import.meta.dirname, 'mutants/operation-mutations.ts')
  console.log(readFileSync(table, 'utf8').length)

at the top of `pilot-recordings.test.ts` gave 2 passed before, and after this change
fails with ["pilot-recordings.test.ts"].

Only non-driver tests are exempt now. This file lives in `mutants/`, which
`recorderSha256` skips, so no golden moves: the recorder suite is green on the
existing 208 with zero dirty.

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

* refactor(mobile): drop the registry parameter no caller varies

`pilotMountAdapters` took `registered` so a caller could mount a different module
set; all six callers take the default. The header-digest tests vary the registry
through `goldenRecording`, which keeps its own parameter and is where the stub
roots need it. Engine source, so `recorderSha256` moves and the goldens follow in
the next commit.

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

* test(mobile): re-record the RPC goldens after the registry parameter came out

All 208, `recorderSha256` only. The re-record against the previous commit moves
416 lines, every one of them that field:

  416 "recorderSha256":

Against origin/main the picture is unchanged from the merge: 208 goldens, 0 added
or deleted, 0 non-header lines, and exactly four keys differing —

  208 "adapterSha256"   416 "baseline"   416 "goldenFormatVersion"   416 "recorderSha256"

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

* docs(mobile): wrap the recording README at the width the rest of it uses

Seven lines this branch added ran past 100 columns, worst 124. No wording changed.
Markdown is outside `recorderSha256`, so no golden moves.

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

* docs(mobile): name the worktree overlay, not the archive that cannot work

`git archive` was offered alongside a detached checkout as a way to lay this
branch's recorder over the pinned baseline. It cannot work: the fence in
scripts/rpc-recording.mts runs `git diff --quiet <baseline>` and an untracked-file
check, both of which need a real `.git`. In an archive tree git exits non-zero for
lack of a repository and the script reports "Product sources or lockfile differ
from the pinned main baseline", which reads as a product mismatch that is not
there. The transport agent lost time to exactly that.

Names `git worktree add --detach` only, and says what the misleading failure looks
like if someone tries an archive anyway. Markdown is outside `recorderSha256`, so
no golden moves.

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

* test(mobile): close two ways an adapter module escapes its own digest

Two holes, one class: the seam was checked by how an import was spelled and by
what the register's values evaluated to, never by where they resolve or where they
were written.

Inward imports: the scan dropped every specifier starting with `..`, so
`'../adapters/settings-mount-adapters'` climbed out of the directory and back into
it unseen. A reviewer had `new-tab-agent-mount-adapters.ts` project a value read
from the settings module, edited that module, and watched the mounted state change
while the new-tab adapter digest held. Specifiers now resolve against the
directory and anything landing back inside it fails:

  ["new-tab-agent-mount-adapters.ts imports ../adapters/settings-mount-adapters"]

The register: `adapters/mounted-operation-modules.ts` is pinned by nothing —
`recorderSha256` skips the directory and `adapterSha256` reads each entry's
`source`. An `exposes` written inline there drives the mounted product module with
no digest covering it. The same reviewer replaced the new-tab entry's `exposes`
with a literal overriding `loadMobileNewTabAgentOptions`; twelve fence tests
passed. Both `mounts` and `exposes` must now be identifiers the register imports
from that entry's own module:

  ["new-tab-agent-mount-adapters.ts writes exposes inline instead of importing it"]

Checked on the register's syntax, not its values, because an inline literal and an
imported binding are indistinguishable once evaluated.

Pinning the register in the engine digest would also close it, and is the wrong
trade: every domain adding a register line would re-digest all 208 goldens, which
is the conflict this PR exists to remove. Keeping the register an index costs
nothing and keeps a domain's line local.

Both fixes live in a `.test.ts` outside the drivers, so no golden moves.

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

* test(mobile): prove the mutant seam from the drivers out, not by spelling

The seam rested on a grep for the literal `mutants`, which the exported
`MUTANT_DIRECTORY` spells without containing. A reviewer had
`pilot-mount-adapters.ts` read the mutant table through that constant and both
checks passed. The README's claim — that nothing on the recording path names the
directory — was false as written.

Three changes, in order of strength:

Reachability is now proved forward. The suite walks the static import graph from
the two recording drivers and fails if any module under `mutants/` is in it. That
answers the real question, what a golden's bytes can depend on, instead of the old
inward scan's question, who mentions this directory. Non-emptiness is asserted on
both sides so a graph that resolved nothing cannot pass by reaching nothing.

The name scan covers both spellings, for paths a module can be read by rather than
imported. The reviewer's probe now fails as ["pilot-mount-adapters.ts"].

`MUTANT_DIRECTORY` is no longer exported. Its two consumers were both tests of the
digest, and they now spell the path instead, which is strictly better for them: a
test that imports the constant follows a rename silently, while one that spells it
fails on a rename — and that specific directory name is the whole soundness
argument. This edits `recorder-digest.ts`, so the goldens re-record in the next
commit.

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

* test(mobile): re-record the RPC goldens after MUTANT_DIRECTORY stopped being exported

All 208, `recorderSha256` only. Against the previous commit the diff is 416 lines
and every one of them is that field:

  416 "recorderSha256":

Against origin/main, unchanged: 208 goldens, 0 added or deleted, 0 non-header
lines, four keys differing —

  208 "adapterSha256"   416 "baseline"   416 "goldenFormatVersion"   416 "recorderSha256"

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

* docs(mobile): state the mutant seam's actual argument, and its edge

The README claimed nothing on the recording path names `mutants/`. That was the
old inward scan's claim and a reviewer falsified it with the exported constant. It
now describes what the check does: a forward walk of the import graph from the two
recording drivers, plus a name scan in both spellings for read-by-path, plus the
constant no longer being exported. It also names the case neither closes — a path
assembled from fragments at runtime.

Markdown is outside `recorderSha256`, so no golden moves.

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

* test(mobile): prove the engine/adapter seam in both directions

The inward scan only held adapters to the seam. An engine file importing an
adapter executes code its own digest skips and that every golden recorded
through another domain leaves out of `adapterSha256`, so the register is now
the only crossing allowed from the engine side.

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

* test(mobile): name what the driver walk missed instead of counting it

Seeding `seen` with the drivers made the driver-presence check true by
construction, and the size bound compared a graph inflated by `typeof import`
product modules against a recorder-sized number. Both go; the walk now reports
the recording files it failed to reach, which is empty today and names an
orphan engine file the moment one appears.

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

* docs(mobile): reflow four paragraphs left ragged by the rewrap

Orphan fragments only, no wording change: the golden-schema field list, the
mutant-evidence paragraph, the probe-witness sentence and the re-anchor note.

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

* test(mobile): record the small-domain call sites before migrating them

Thirteen new families cover the files, new-workspace, host-screen, home and
worktree call sites step 4 migrates next: ownership capture, the preview loader
and its terminal-artifact grant refresh, the artifact save, the tab doc's three
shapes, the drawer's execution target and setup hook, the Codex reset-credit
probe, the host view settings, the Home stats card and the three workspace
catalog reads.

Recorded against main's product code, so these are the parity baseline the
refactor must not move. Four new adapter modules under adapters/ and no engine
edit, so recorderSha256 is unmoved and every existing golden is byte-identical:
40 files added, none changed.

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

* refactor(mobile): send the small-domain reads through RpcOperation

Thirty-five of the domain's fifty-five raw-port references now go through a
declared operation: the files domain's preview, artifact and tab-doc reads and
its ownership capture, the New Workspace drawer, the host screen's metadata and
view-settings mirror, the Home stats card, and all three workspace catalog reads.

No behaviour change, and the oracle says so: zero goldens move. Acceptance is
preserved call site by call site, including two that look like defects and stay
that way — a refused worktree.listRetiredNames still settles as an empty registry
rather than holding the previous names, and a refused ui.get on a null result
still throws into the host screen's own catch.

Where two call sites disagreed about one method, both policies are named: files.read
and files.readPreview throw for a session file tab and skip for the preview screen,
repo.hooks throws for task create and skips for the drawer, and status.get now
carries a fourth family for the Codex capability probe's object-or-null rule.
The drawer's SSH connect, SSH state and agent detection reuse the workspace-create
operations the tasks migration already declared rather than restating them.

Two things outside the call sites. requestSingleFlight now shares the params
optionality rule that request already had, so an all-optional schema such as
preflight.check can omit its params on both helpers instead of only one; that is
type-level and puts nothing new on the wire. And the retired-names fixture
resolved a reply with no `ok`, a shape no host sends, which read as a refusal once
the acceptance policy routed on it.

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

* test(mobile): drive the image arm of the preview loader

The mutation census found two migrated operations that no recording reached:
repointing files.readPreview or files.readTerminalArtifactPreview to a wrong
method, and swapping either one's acceptance policy, changed nothing any golden
observed. Both preview-load scenarios read a text path, so the loader's image
branch was migrated with no wire behind it.

Two scenarios now read an image path through each arm, and the adapter takes the
path from the scenario instead of a constant. All four mutations die on the new
goldens. They are recorded from the pinned baseline with this branch's recorder
laid over it, so they are main's behaviour and not the migration's: the candidate
run against the refactored tree compares clean.

The adapter edit re-digests the nineteen goldens mounted through it. The diff is
one adapterSha256 line each and no observation moves, which is what pinning the
adapter per golden rather than per suite is for.

Two casts also take the SAFETY form the house style asks for.

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

* refactor(mobile): drop the casts the changed-code gate flags

Seven type assertions the gate counted as new, all removed rather than silenced
where the type system could already answer. `normalizeMobileFilePreviewResponse`
narrows on `ok` instead of asserting each arm; the snapshot adapter narrows on
the fetch result's own discriminant; and the drawer's two probe reads go through
one overloaded member read that keeps their optional-chaining behaviour. The
remaining three keep a cast and now carry the rationale on the asserting line.

No behaviour change. The two adapter edits re-digest the sixteen goldens mounted
through them, one adapterSha256 line each with no observation moved, recorded
from the pinned baseline the same way.

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

* refactor(mobile): drop the import the narrowing left behind

RpcSuccess is no longer named once the response reads through its own discriminant.

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

* test(mobile): record the host screen's worktree mutations

Review of #20705 showed the `use-host-worktree-actions.ts` holdout reason was
wrong: its only native call is the pinned-id write, and that sits behind
`if (hostId)`, so mounting with no hostId never reaches it.

Two scenarios in one new family, recorded from the pinned baseline with the call
site still on the raw port. The first drives all three sends so the reply matrix
covers each method; the second refuses `worktree.rm` to pin the row restore.

The adapter is a new module, so no existing golden's `adapterSha256` moves and
none of the 250 goldens already here is rewritten.

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

* refactor(mobile): send the host list's worktree mutations through RpcOperation

Pin, remove and activate move onto operations in host-screen-operations.ts. All
three skip on refusal, which is the policy each site already applied by hand: the
pin and activate writes swallow everything in a `.catch`, and the remove restores
the row on a refused reply. `worktree.set` therefore carries a second policy next
to source-control's `worktree.set-review-link`, which throws; both are named.

Zero goldens move. The inventory loses use-host-worktree-actions.ts and states
the real reason the drawer's repo list stays: it renders the last-visited-repo
hook, whose default import of async-storage the recorder's proxy refuses at
module load, before the hostId guard the reviewer expected to save it.

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

* test(mobile): type the worktree-action fixture row and follow the activation scan

The fixture row I recorded from had four fields, which `tsc` rejects as a
`Worktree`. Filling it out moves the five goldens of this branch's own new family
and nothing else; the recorded sends are unchanged, only the projected row is.

`mobile-worktree-activation-source.test.ts` scanned the hook for the literal
`sendRequest('worktree.activate'`, which the previous commit replaced. It now
asserts the operation call and its two flags in the hook, plus the method in
host-screen-operations.ts, so the pair still pins the same wire.

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

* refactor(mobile): take the five deletions round-1 review asked for

- `fileOwnershipRuntimeStatusRead` was `taskRuntimeStatusRead` field for field.
  It is now a re-export of it. The goldens are keyed on the scenario family, not
  the operation name, so `matrix-files.mutation-ownership-status.get-1.json`
  survives unchanged.
- `readProbeMember`'s two overloads asserted shapes nothing checked. Gone; the
  nested read goes through the same single-signature function.
- `normalizeMobileFilePreviewResponse` had no product caller. Deleted with its
  re-export; its twelve assertions now drive the accepted and refused arms
  directly.
- The three inline copies of the accepted-result union are gone. They name each
  operation's own `interpret` return instead of importing `RpcAcceptedResult`:
  importing the contract would pull all three call sites into the cast fence,
  where their existing SAFETY assertions fail it.
- `codex-reset-credit-capability-operation.ts` is now `-operations.ts`. No
  adapter names it, so no golden re-digests.

Zero goldens move.

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

* refactor(mobile): give the skip verdict its own transport module

The three settle helpers typed their interpret parameter as
`ReturnType<typeof <op>.interpret>`, naming one operation while being called with
others whose verdicts happen to be structurally identical. Narrowing a named
reader would have silently retyped unrelated helpers.

`RpcAcceptedResult` moves to `rpc-accepted-result.ts`, outside the cast fence's
three region seeds, so a consumer can name the verdict without becoming an
operation implementation. `rpc-operation-contract.ts` re-exports it.

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

* refactor(mobile): drop three sender aliases nothing imports

MobileHostScreenRpcSender, MobileNewWorkspaceRpcSender and
MobileWorktreeCatalogRpcSender each appeared only in the file that declared
them. A named type with no consumer is a cost, not a boundary.

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

* docs(mobile): say why a holdout is a holdout in the port inventory

A site can be pointed at an operation without being mountable, so "cannot be
migrated" was the wrong claim. The rule is record-first: the golden recorded
against the old code is the only parity proof, so a site the recorder cannot
mount cannot be recorded, and unrecorded sites do not migrate. Stated once in
the list's header.

codex-reset-credit.ts loads fine under the module loader; probed it, and its
attempt-journal access throws on async-storage at call time before the send,
with no guard to skip it. The old comment described it as a storage read
around the send.

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

* docs(mobile): state the status.get policies without counting them

"the fourth policy on this method", "the first of two" and "the second of two"
were already wrong after round 1 folded the files family's status read into the
tasks one. Each comment now states its own invariant, which no later policy can
invalidate.

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

* test(mobile): stop the activation scan claiming to pin the wire

`expect(operations).toContain("method: 'worktree.activate'")` passes whichever
operation carries that method, so it survives swapping worktreePinWrite's and
worktreeActivate's methods. tsc and the host-worktree-actions-pin-open-delete
golden both fail on that swap; the scan keeps only what it can prove, which is
that the callback sends through worktreeActivate with the two flags.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 13:10:32 -04:00
Jinwoo Hong 85d7cf3cc1 fix(mobile): preserve delivery ambiguity across transport cutover (#20280)
* fix(mobile): preserve delivery ambiguity across transport cutover

Let physical close settle requests and retain its error as the cutover cause, copying only an existing delivery-unknown mark. Pin sent and unsent caller outcomes and both cutover predicate carriers.

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

* docs(mobile): pin the RpcClient.close() settlement contract

close() was declared `() => void` with no stated obligation. That was harmless
while migrateTo rejected pendings itself; now that it does not, close() is the
retiring generation's only settlement path, so a type-compatible implementation
that leaves a request pending strands its caller for good.

States the obligation on the declaration and pins it for both trackers the real
implementations reject through. Dropping the delivery-unknown flag, dropping the
relay mark, or leaving pendings in the map each fail a test.

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

* test(mobile): read the cutover cause without a type assertion

main's new casting gate rejects `(error as Error).cause`; narrow instead so the
assertion still distinguishes a missing cause from an unmarked one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-13 18:23:13 -04:00
Jinwoo Hong d936d8da82 revert(mobile): pull the relay connect-speed mobile pass pending a smaller, verified re-land (#19348)
* Revert "feat(mobile): time relay dial stages so diagnostics say where a slow connect went (#19245)"

This reverts commit 83b1558ecc.

* Revert "perf(mobile): race the direct and relay dials from t=0 on every reconnect (#19308)"

This reverts commit ceafdcad2f.

* Revert "feat(mobile): draw the last known tab strip while a session reconnects (mobile pass) (#19281)"

This reverts commit 643571def6.

* Revert "perf(mobile): open a session with parallel startup RPCs and a pre-warmed terminal engine (#19260)"

This reverts commit c37413271e.

* Revert "perf(mobile): cut the relay reconnect critical path and admit dead sockets faster (mobile pass) (#19280)"

This reverts commit e628090ad4.

* chore: keep the react-doctor suppression for the startup timers

The pattern it covers (a variable number of timers cleared through one cleanup)
predates #19260 and is unchanged by the revert; dropping the entry only re-exposed
a pre-existing finding to the changed-code gate.
2026-09-07 16:44:26 -04:00
Jinwoo Hong c37413271e perf(mobile): open a session with parallel startup RPCs and a pre-warmed terminal engine (#19260)
Startup RPCs now fan out in parallel and the xterm engine pre-warms inside the
real terminal frame while they are in flight, so the first pane inherits a warm
WebView and an already-measured viewport instead of paying a round trip for it.

The pre-warm opens its engine before measuring: web-ready only reports that the
bundle loaded, and the WebView answers a measure with null until a terminal
exists. It also pre-warms at the user's saved text size, because cell size is
what the frame height gets divided by.

Host writes such as worktree.activate wait for an evaluated status.get reply.
Navigation still fails open when a host cannot answer one, but that fallback no
longer reads as a passing compatibility verdict.
2026-09-07 13:16:44 -04:00
Brennan BensonandMerge Sim 7f8eb90ac3 Align worktree host labels across desktop and mobile (#18237)
* refactor: align worktree host labels across clients

* fix(mobile): expose safe host display labels

* fix(mobile): preserve legacy mixed-host labels

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-02 15:32:06 -07:00
Neil 3db5a6aab8 perf(mobile): index slept worktree identity lookups (#17512) 2026-08-30 20:47:58 -07:00
Neil 15abc3fe26 Split mobile host screen layers (#17181)
* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Split automation dispatch event handling

* Split settings navigation metadata

* Split daemon initialization lifecycle

* Split GitLab item dialog

* Split relay dispatcher layers

* Split mobile host screen

* Retarget mobile view settings source test

* Fix F3-speech for #17123

* Fix F1-cycle for #17131

* Fix F4-navtest for #17157

* Fix F2-allowlist for #17161
2026-08-29 20:16:28 -07:00
Jinwoo Hong 5c10bf9001 fix(sta-5781): stop cross-client resets of workspace view preferences (#17057) 2026-08-28 15:20:21 -07:00
Jinwoo Hong bf5660df51 feat(mobile): add causal network diagnostics (#16837) 2026-08-27 21:42:23 -07:00
Brennan Benson d2a35eebe3 fix(mobile): avoid unsupported Hermes array sorting (#16506) 2026-08-25 17:11:09 -07:00
Neil aa4c9c707c Refactor mobile home, worktree modal, and RPC client (#16165)
* refactor(mobile): split home modal and rpc client

* fix(mobile): restore render-phase remount key in NewWorktreeModal

The split moved the form-reset epoch from render-phase refs into
useState + useLayoutEffect, which changed when the remount key is
computed. On the render where visible flips false->true the key was
still the old epoch, so the previous session's NewWorktreeModalContent
rendered with visible === true carrying stale form state. Child layout
effects run before the parent's, so visible-gated hooks
(useNewWorkspaceRepositories, useNewWorktreeDrawerNavigation,
useNewWorkspaceRuntimeContext) fired for that stale instance before the
parent bumped the epoch and remounted.

Restore the ref-based computation so the key is correct on the first
render where visible flips true, keeping the composite open/client
epoch semantics and the file split intact.
2026-08-24 23:50:10 -07:00
Brennan BensonandNeil ec4687c434 feat(agents): distinguish Claude background monitoring (takes over #14205) (#16201)
* feat(agents): distinguish Claude background monitoring

Adds an optional `workingMode: 'monitoring'` discriminator for a Claude
session whose lead turn finished but which still has background shell tasks
or session crons registered. The wire state stays `working`, so older peers
that never read the field keep rendering Working.

(cherry picked from commit d5d54b4bdd)

Rebased onto current main (554 commits of drift) by Brennan Benson;
conflicts resolved by keeping both sides where main and this branch made
independent additions to the same construct.

* fix(sidebar): keep monitoring status visible

(cherry picked from commit fd6b38654d)

* test(agents): cover Claude monitoring drain

(cherry picked from commit ce4d61ebf8)

* test(mobile): avoid unresolved renderer test type

(cherry picked from commit bbcfa35ff9)

* feat(agents): render Claude monitoring as a static turquoise dot

Replaces the yellow Radio glyph from #14205 with a static dot in a new
--agent-monitoring token (#8abeb7), defined once for light and once for
dark like --workspace-status-done, so the status keeps its identity when
the theme flips. Deliberately a fixed UI value: it never reads terminal
theme state at runtime.

Adds the turn-boundary notification pins. The monitoring predicate and
the turnCompletedAt stamp are computed from the same "lead said done but
the pane resolves to working" expression, so a rename can silently drop
the stamp and kill a completion notification that works today with
nothing else going red.

* revert(agents): restore the yellow Radio glyph for monitoring

Brennan chose #14205's original treatment over the turquoise dot, so the visual
goes back to nwparker's: lucide Radio in text-yellow-500 across the sidebar,
dashboard dot, cmd-j palette and agent-map ring.

Reverts only the visual surface. The turn-boundary notification pins stay — the
monitoring predicate and the turnCompletedAt stamp share an expression, so a
rename can silently drop the stamp and kill a completion that works today with
nothing else going red. The --agent-monitoring token is removed with its last
consumer rather than left dead in main.css.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-08-24 15:24:32 -07:00
Brennan Benson fab6e0d6e7 fix(mobile): scope optimistic workspace removal to the deleted host (#15424)
* fix(mobile): scope optimistic workspace removal to the deleted host

A worktreeId repeats across hosts, so filtering the list on the bare id also
removed the identically-named workspace belonging to the other host. Match on
(worktreeId, hostId) through a named helper so the rule is testable.

* fix(mobile): key host worktree rows consistently
2026-08-19 17:12:44 -07:00
Jinjing 7aaa7c6f5b refactor(sidebar): group worktree-list files by domain (#14486)
* refactor(sidebar): group worktree-list files by domain

Follow-up to #14465 / #14467. Keep the landed extract and reorganize the
flat worktree-list dump into drag/, headers/, reveal/, rows/, scroll/,
and viewport/. Fold tiny modules into their owners, move leftover
sidebar-root files into the module, and retarget imports and source-path
tests. Layout-only; no behavior change.

* fix(sidebar): merge duplicate virtual-rows imports

Inlining virtual-row-dom-attributes left a second import from the same
module, which fails audit:code-quality:native --deny-warnings.

* refactor(sidebar): condense indentation comments

Shorten explanations to focus on the essential why, removing redundant
detail and improving readability without changing functionality.

* refactor: organize worktree-list into lifecycle dest folders

* fix react doctor

* fix: update reliability-gates path after worktree-list reorg

host-filtering.test.ts moved from viewport/ to listing/; keep the
runtime-routing.active-server-preference gate pointing at the real file.

* Extract workspace status colors to design tokens

Define theme-aware color tokens for workspace PR-state indicators (done, in-review, in-progress) to ensure consistent identity across theme switches. Update references to use the new tokens and refactor EmptyState button to use the Button component.

* fix(sidebar): stop mutating refs during worktree-list render

React Doctor fails static analysis when refs are written in render.
Commit reused array identity and the Smart live-signal latch after
paint, and return the attention map from the sort memo instead of
stashing it on a render-time ref.
2026-08-15 13:40:09 -07:00
Brennan Benson ab9d1a29a9 fix(worktree): never reissue a generated workspace name (#14350)
* fix(worktree): never reissue a generated workspace name

Generated workspace names were deduped only against currently-live
worktrees, so deleting a workspace returned its name to the pool. A later
workspace could draw the same name, land on the same directory path, and
inherit the previous occupant's agent conversation history — coding-agent
CLIs key their prompt history and transcripts by cwd.

Names are now retired permanently per repo. The registry is written in
main with the name Git actually used (the create loop can advance past a
requested name on collision), and seeded once per run from workspace
directories and surviving agent transcript buckets so already-spent names
are excluded from the start. Suggestions degrade to -2, -3 variants
instead of recycling, and those variants retire too.

User-typed names are untouched: retirement filters suggestions only.

* fix(mobile): honor retired workspace names, on one shared implementation

Mobile hand-duplicated the desktop name-suggestion algorithm and deduped
only against live workspaces, so a phone could still be offered a name
whose deleted workspace left agent conversation state behind at that path.

Both platforms now call one shared selector in src/shared, so the two can
no longer drift. The host publishes retired names as an optional field on
the existing worktree.list response, and mobile fetches them per selected
repo while the create sheet is open — mirroring the desktop hook.

Mobile never calls worktree.list for its catalog (it uses worktree.ps,
which carries rows only), so this is a targeted request rather than a
change to the catalog or its cache. Hosts predating the field omit it and
mobile falls back to live-only dedupe, which is the pre-change behavior.

* fix(worktree): close retirement consistency gaps

* test(worktree): cover retirement runtime contracts

* fix(worktree): retire generated collision names

* fix(worktree): enforce retired names at creation

* refactor(ai-vault): extract the Claude project-dir encoder

The bucket-name encoder and its scope-boundary check were private to the
session scanner, so a second consumer had to reimplement them — and got the
per-character encoding wrong. Move both to a shared module with direct tests.

* fix(worktree): make the retirement seed scan actually match buckets

The bucket encoder collapsed runs of non-alphanumerics while the real one
emits a dash per character, so every dot-path bucket missed and the Windows
default workspace root (C:\...) matched nothing at all. Reuse the shared
encoder and its boundary check, which also stops a repo absorbing a sibling
whose path merely shares its prefix.

Also:
- Derive the workspace leaf by stripping the known encoded parent instead of
  guessing from trailing dash segments, which retired the parent directory's
  name whenever a workspace was named numerically.
- Reuse isAutoGeneratedCreatureBranchName so the -10 and -100 tiers retire.
- Drop the .codex/sessions root: Codex keeps the cwd inside the transcript
  rather than in a directory name, so the scan could only ever see a year
  folder. Reading transcript contents is not a trade this feature justifies,
  so the gap is documented instead.
- Honor CLAUDE_CONFIG_DIR, which relocates the bucket root.
- Delete the unused retirableLeafName export.

Tests write buckets with the real per-character encoding against a fake home,
covering POSIX, dot-directory, Windows drive and WSL UNC roots; all three
platform cases fail against the previous encoder.

* fix(worktree): retire only generated names, keyed by cwd namespace

Two problems in the host-side registry.

Retirement fired for every create, including names the user typed. The
creature pool contains ordinary words — orca, runner, sole, molly, oscar — so
typing a retired 'nautilus' silently produced directory and branch
'nautilus-2' and burned the name for good. Creates now carry an explicit
nameWasGenerated flag; both the skip and the retire are gated on it, and it
defaults to false so CLI and automation callers are unaffected.

The registry was keyed by repo id, but both readers already discarded the id
and unioned by the cwd collision key, because the collision this prevents is
on the path. Keying by that namespace directly fixes several things at once:
entries no longer orphan when a repo is removed, remove/re-add no longer loses
every retirement for an unchanged path, the missing removeProject prune is
moot, and the backfill promise no longer merges into only the first repo id it
saw. The feature is unreleased, so no migration is needed.

Also:
- Memoize the collision key. It runs computeWorktreePath, which for a WSL repo
  is a blocking execFileSync('wsl.exe') whose failure path is uncached, and
  the previous code recomputed it once per repo on every create and every
  listRetiredNames call.
- Drop retiredNamesByRepo from the worktree list result. It had no readers and
  leaked onto 'orca worktree list --json', and its awaited backfill sat on CLI
  selector resolution. The dedicated listRetiredNames RPC keeps its consumers.
- Make the three RuntimeStore methods required. RuntimeStore is file-private
  with two constructors, so the 'older embedders' the optionality protected do
  not exist, and the optional chain silently returned no retirements.
- Revert the unrelated forceDeleteBranch rewrite, and make room under the
  file's line budget by extracting the create-args mapping instead.

* fix(worktree): send name provenance and stop gating Create on the fetch

Desktop and mobile now mark a create as generated-name only when the user
typed nothing and the composer fell back to the suggestion, so the host knows
which names it may retire.

Remove the retired-names loading gate from every create path. The host already
skips retired candidates before doing any git work, so the client gate bought
nothing while it could disable Create for the length of a full mobile
reconnect ladder (the wait had no timeout) and blank the desktop button
between queued creates. The suggestion still waits; the button never does.

Also make the web client call worktree.listRetiredNames instead of hardcoding
an empty list — the method is registered and mobile-allowlisted, so the
comment claiming no wire call existed was wrong — and filter the mobile
response to strings so a malformed row cannot throw during normalization.

* fix(worktree): key retirement by repo id and prune it with the repo

Reverts the collision-key storage key. It was a function of workspaceDir,
nestWorkspaces, worktreeBasePath and repo.path, so toggling any one of those
orphaned every retirement for every affected repo at once — trading a rare
churn (remove/re-add) for a common one. The read path already unions by cwd
namespace at query time, so cross-repo sharing never depended on the storage
key.

Instead, address the growth and orphaning directly:
- Drop the registry in removeProject, and in removeProjectForHost once the last
  host's copy of the repo id is gone, alongside the sparse-preset deletes that
  already follow this convention.
- Bound each repo's registry. The cap sits far above the 552-name pool because
  evicting inside it would reissue a name whose agent state is still on disk;
  only -2/-3 tier accumulation can ever reach it.
- Carry retirements through profile transfer, re-keyed to the destination repo
  id and dropped from the source, mirroring sparsePresetsByRepo.

Separately, fix the backfill merge: the scan promise is cached per cwd
namespace, but it closed over the first repo id that triggered it, so a second
repo in the same namespace received nothing. The scan stays shared; the merge
moves out of the cached promise and runs for whichever repo asked.

Local repos re-seed on re-add through that backfill. SSH repos do not — the
scan cannot see the execution host — which is now stated in the module.

* docs(worktree): spell out why the retirement bound sits above the pool

Names the trap directly: the neighbouring 50/200 bounds cap histories, so
lowering this one to match them would silently start reissuing names whose
agent state is still on disk. Also states that oldest-first eviction is a
deliberate least-bad choice rather than a neutral one.

* fix(worktree): send name provenance from the web runtime client

This client hand-enumerates worktree.create params, so the new optional field
was silently dropped and typecheck could not see it. On web and paired-desktop
the host therefore never received it: generated names were never retired, and
the host-side skip that backstops a stale suggestion was disabled too. The same
client does fetch retired names for suggestions, so it was filtering against a
registry nothing ever wrote to.

The test asserts both directions, and fails without the fix.

* fix(worktree): retire names that took more than one collision suffix

isAutoGeneratedCreatureBranchName strips exactly one trailing -N, which is
right for auto-rename eligibility but wrong here. Once the pool is spent the
suggester emits nautilus-2, and a collision on that yields nautilus-2-3 —
which a single strip leaves as nautilus-2, not a pool name, so retirement
no-opped at exactly the tier where every base name is already gone. Strip
repeated suffixes locally rather than moving the auto-rename predicate.

* perf(worktree): keep the retirement backfill off the blocking WSL probe

The backfill runs on composer repo-select, not just at create time, and it
derived the probe path synchronously — which for a WSL repo with a mirrored
workspace dir reaches getWslHome and its blocking execFileSync('wsl.exe').
A stopped distro froze the main process for up to 5s on composer open.

Adds an async twin of computeWorktreePath and uses it for the probe. Resolving
the home there also warms the shared cache, so later sync callers are free.

Also stops memoizing the collision key when the WSL home is still unresolved:
only the success path is cached upstream, so caching the fallback namespace
would strand the repo there for the rest of the session.

* fix(worktree): hold retired names across a refresh instead of blanking

refreshKey changes on every workspace-list mutation, so create-multiple
refetches after each create and the hook returned an empty list until the
refetch landed — precisely the window in which resetForNextCreate clears the
name field and a fresh suggestion is drawn. Keep the previous answer while
revalidating and reset only when the repo changes; a failed refresh keeps what
was already loaded rather than un-retiring everything.

Also makes the returned array referentially stable, so the suggestion memo
downstream stops rerunning on every refetch.

* refactor(worktree): put the retired-name cache rules on one implementation

The desktop and mobile hooks that fetch retired names had already drifted
four ways. The transports genuinely differ (IPC vs RPC), but the caching
rules must not, and mobile's copy reset to [] on any error -- which
un-retires every name for the rest of the sheet session, the one outcome
retirement exists to prevent.

Moves the rules into src/shared/worktree/retired-name-cache: response
normalization, the never-leak-across-repos rule, and the hold-previous-on-
failure rule. Pure, no React, because src/shared is on the main process's
import graph. Each platform keeps its own transport and effect.

Mobile moves up to desktop's behavior: it now holds the previous answer
through a failed refresh, and refetches when the workspace list changes
instead of never refetching after mount.

Also drops the unused `loading` return. Neither platform consumed it; its
only consumer was the Create-button gate reviewed out earlier, and removing
it makes that regression unexpressible.

* fix(worktree): import shared types from their real modules

Main dropped the src/shared/types barrel, so the retirement module's import
resolved locally but not against the PR's merge base.

* refactor(worktree): bound the retirement registry by tier compaction, not eviction

Retirement is a correctness guarantee — a spent name's directory may still hold
agent conversation state keyed by that cwd — so the 2000-entry cap was the wrong
shape: reaching it handed a name back. At the owner's measured rate (~6.6 pool
names retired per day in one repo) the cap was ~9 months out.

Names come from a fixed 552-entry pool and the suggester only reaches tier N+1
once every tier-N name is taken, so a completed tier is exactly a set that no
longer needs listing. A row is now a watermark plus the names above it: reads
answer at-or-below the watermark with no lookup, and compaction drops the 552
entries the watermark now covers. Bounded at one pool per repo forever, with no
eviction and nothing un-retired.

Tiers can complete out of order (a create-time collision can spend `nautilus-2`
while tier 1 is open), so compaction loops and higher-tier names simply wait.

The RPC result carries the watermark beside the names as a new field; a client
predating it reads the names only and under-retires the compacted tiers, which
degrades to the pre-retirement behavior rather than breaking.

* fix(worktree): preserve generated name retirement across failures
2026-08-14 22:18:36 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00
Jinwoo HongandOrcaWin 738f640428 fix(mobile): relay UX overhaul — steady status colors, visible relay dials, coordinated deep links (F1-F10)
* fix(mobile): keep healthy relays green through focus and network nudges (F1+F2)

Focus/app-resume nudges probe the active relay instead of suspending it;
network-change nudges replace it make-before-break, suspending only after a
failed dial. Mount, Retry, and host-swap windows read 'connecting' instead of
'disconnected'; the host list keeps last-known worktrees for every
not-connected state and spins instead of rendering nothing.

* feat(mobile): surface the pairing relay path in the pairing log (F3)

The relay candidate was silent during pairing: dialing, E2EE handshake,
director recovery, and the winning path now emit redacted phase lines through
the same connectOptions.onLog the direct path already used.

* docs(mobile): relay UX investigation findings and F0-F10 fix plan

* feat(mobile): name and narrate relay dials while they happen (F5)

migrateTo forwards the dialing session's connecting/handshaking/reconnecting
phases whenever the client is suspended or disconnected — never downgrading a
live session — and exposes getPendingPath so the host card can say
'· Orca Relay' during the dial instead of only after it.

* feat(mobile): race a relay dial when the direct dial stalls (F6)

A 2.5s grace timer starts relay recovery while an unauthenticated direct dial
is still inside its 12s connect window; the race gets one attempt through the
existing mutex/cooldown machinery, cancels when direct authenticates, and
never arms for hosts without a relay endpoint.

* fix(mobile): overlay the protocol gate instead of unmounting the host stack (F9)

A pending status.get used to swap the mounted HostStack for a spinner at the
moment the socket connected, destroying in-flight nested navigation. Once
children have rendered for a host they stay mounted under an opaque
touch-blocking overlay; first visits and blocked verdicts keep the old
behavior.

* fix(mobile): keep loaded data through transient connection blips (F10)

Git history no longer blanks on reconnect (and commit files refetch instead
of caching an offline empty answer), the repo picker keeps its last-good list
when an in-flight repo.list rejects, the diff review's ready-state
preservation actually runs, and proven host capabilities survive a drop
flagged unverified instead of being wiped.

* feat(mobile): coordinate every home deep push and bounce dead resume targets (F4+F7+F8)

Notification taps, the Accounts card, and host-edit now use the shared
mount-then-replace transition (with a focused-route walker so root-layout
scope works); the Resume card renders from the snapshot in a disabled state
so its late arrival can't shift Tasks under the thumb; resume targets are
validated against proven catalog data, and a session route whose worktree
the host proves missing bounces to the host index with a notice banner
instead of stranding on a dead screen.

* test(mobile): cover the resume-target and notice policies (F7)

Key notice dismissal by code so closing one banner cannot swallow a later,
different one, and move the visibility rule into host-route-notice.ts where it
is testable without a screen.

Adds the missing units for F7's decision points: isResumeTargetConfirmedMissing
(unproven catalog is silence, synthetic routes exempt), the validating
last-visited reader, and the notice visibility rule.

* fix(mobile): review-pass hardening for the gate overlay and diff preservation

Adversarial review findings: the reader's hunk position now survives a
connection blip (reset only on item change), the covered stack is hidden from
TalkBack while the gate overlay is up, and the overlay's hit-test comment is
scoped honestly to in-tree views (native-Modal drawers present above it —
follow-up).

* fix(mobile): CI + CodeRabbit review fixes for #12609

Move the findings doc under docs/ (root directory guard), drop two unused
eslint-disable directives, and address review findings: an unproven snapshot
seed can no longer downgrade a proven worktree catalog; a locally-aborted
relay dial skips the director fallback; post-migration bookkeeping failures
log instead of masquerading as dial failures (which could suspend the healthy
session); the auth wait arms its timeout before subscribing; forwarded dial
phases stop at close(); the legacy selector_not_found fallback requires
runtime_error; the diff-loading effect depends on the fields it reads; and
host-edit auto-cancellation is now pinned by a test.

* fix(mobile): second review round — queued replacements, race fence, confirmed bounces

A network-change replacement now survives the recovery mutex and cooldowns as
a queued intent instead of being dropped or suspending a healthy session —
only a failed dial or a dead probe tears one down. The happy-eyeballs
migration withdraws when direct authenticated during the relay dial
(first-authenticated-wins). A worktree bounce requires two consecutive
host-proven misses, since a transient desktop repo-scan rejection answers
selector_not_found for a live worktree. Background network flaps no longer
wake a billed relay splice, the lifecycle foreground flag stays in sync, a
screen unmount cancels only its own pending host-stack transition, and diff
review keeps the loaded review when its reconnect refresh rejects.

Extracted mobile-endpoint-nudge-router.ts and the establisher's dialEligible
pass, and split the supervisor nudge tests, to stay under max-lines.

* fix(mobile): satisfy the React Doctor changed-code gate

Render-phase ref writes move into effects: the protocol gate's resolved/mounted
latches now record committed outcomes only (a discarded children render can no
longer count as mounted), and the bounce hook syncs its callback ref in an
effect. Array<T> annotations become T[] in the extracted modules.

* fix(mobile): keep the loaded diff when the reconnect refetch rejects (F10)

The diff-loading hook's catch was the one path still erasing a ready diff —
the same keepLoadedDiff guard its disconnect and loading branches already use,
now pinned by a reject-after-ready test.

* fix(mobile): process foreground revival nudges

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 19:47:39 -07:00
NeilandOrca 96e31e7bf8 Remove a paired computer's deleted projects from every connected device (#12215)
* fix(repos): remove a paired computer's deleted projects from every connected device

A project deleted on a paired Orca host stayed in every connected client's
sidebar and could not be removed there.

Two independent defects:

1. Host-local repo IPC mutations only sent `repos:changed` to the host's own
   renderer (src/main/ipc/repos.ts:2711). The runtime client-event stream was
   fed only by mutations arriving over runtime RPC, and clients refetch a remote
   catalog only on a `reposChanged` event -- there is no polling on desktop -- so
   the deleted rows persisted indefinitely. The shared `notifyReposChanged`
   helper now also calls the new
   `OrcaRuntimeService.notifyReposChangedForRemoteClients()`
   (src/main/runtime/orca-runtime.ts:5175), mirroring the existing
   `notifyWorktreesChangedForRemoteClients` precedent. This covers every repo,
   project-group and folder-workspace IPC mutation, so renames, colors, reorders
   and adds propagate too.

2. Deleting the ghost row on the client routed `repo.rm` to the owner, which
   answered `repo_not_found`. `removeProject` wrapped its whole body in one
   try/catch, so the rejection aborted the local purge before the `set()`
   (src/renderer/src/store/slices/repos.ts:3466) and the delete button appeared
   to do nothing. Only `repo_not_found` is now tolerated; any other failure still
   keeps the row, and an opt-in `errorFeedback: 'toast'` makes it visible at the
   three single-project user-initiated entry points. Bulk and background callers
   keep today's silence plus their own aggregate reporting.

Closes #11994

Co-authored-by: Orca <help@stably.ai>

* fix(repos): revert inert RepositoryPane removeProject arg

The settings pane's only render site drops the argument; the toast is
already delivered by removeSettingsProjectFromAllHosts.

Co-authored-by: Orca <help@stably.ai>

* fix(repos): scope duplicate-repo-id deletes to the owning execution host

Cover the cross-host collisions #11994's broadcast now fans out to every paired
device. Same-name projects on different hosts were already isolated (per-host
UUIDs, host-scoped catalog merge and purge) and are pinned by regression tests.

Two same-repo-id paths were not: `repo.rm` with a `path:`/`name:` selector and
`deleteProjectHostSetup` both resolved one row and then deleted by bare id,
taking the sibling host's registration with it.

Co-authored-by: Orca <help@stably.ai>

* test(mobile): align the poll-interval rationale with the new reposChanged emission

Co-authored-by: Orca <help@stably.ai>

* fix(repos): resolve deleteProjectHostSetup's repo row only on the setup's own host

The sibling-host fallback could only ever pick a row on a host the caller
did not name; with no exact match the setup is stale and the existing path
already drops just the setup.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:26:48 -07:00
OrcaWinandOrcaWin fb1259a09d fix(mobile): keep cached workspace counts across a transient RPC failure (#12408)
* fix(mobile): keep cached workspace counts across a transient RPC failure

The Home host card showed "12 worktrees · 2 active" until any worktree.ps
failed — a backgrounded app, a Wi-Fi→cellular handoff, or a sleep/resume
that kills the socket mid-request. Two things then went wrong:

- render dropped the counts: `markHomeWorktreeCatalogUnavailable` kept the
  proven numbers in state, but the card only rendered them when
  `catalogUnavailable` was unset, so the line collapsed to "Worktree list
  unavailable" even though the last successful counts were right there.
- nothing re-drove the fetch: the per-host wiring latched a `statsFetched`
  boolean on the first connect, and the logical client survives socket
  drops, so its reconnect never re-read the catalog. The card stayed wrong
  until the user navigated away and back.

Keep the proven counts and flag them stale (`staleCounts`), rendered as
"Last known: 12 worktrees · 2 active"; a host whose catalog never loaded
still reads "Worktree list unavailable" (STA-3123). Replace the one-shot
latch with createHostConnectRefetchGate, which fires on each transition
INTO 'connected' — one refetch per reconnect, no polling timer — mirroring
useWorktreeResync on the host screen. fetchHomeHostWorktreeInfo moves out
of app/index.tsx so its rejection path is covered by tests.

* fix(mobile): bound "Last known" counts and survive a path cutover

Review found two ways the home host card's stale-count fix misbehaves.

1. A migrateTo cutover (relay->direct probe, forced replacement) rejects
   in-flight requests with LogicalClientCutoverError and republishes
   'connected' from 'connected', so the connect gate never re-arms and the
   card latched on "Last known: ..." with nothing left to clear it.
   worktree.ps now re-issues on the authenticated replacement, bounded,
   like runtime-capability-probe and worktree-create-retry already do.

2. "Last known: N worktrees" had no age bound. The home snapshot is
   persisted, so a cold start whose first worktree.ps failed rendered
   counts proven days ago exactly like counts proven seconds ago - the case
   STA-3123 deliberately rendered as "Worktree list unavailable". Counts now
   carry countsProvenAt and expire out of the "last known" wording after
   10 minutes; counts persisted by an older build count as expired.

Also, per review: the card derives its own worktree line from
HostWorktreeInfo, so a caller can no longer re-gate the counts away (that
was the original defect), and the derivation is covered by a render test -
mobile/vitest.config.ts never collected *.test.tsx, so component tests
were silently dead. Home stats are keyed by host and summed instead of
letting whichever desktop replied last overwrite the shared header row,
which the per-reconnect refetch made churn on flaky links.

* fix(mobile): age bounds liveness, not the counts; scope the header total to paired hosts

Round-2 review follow-up.

Age bound was anchored on proof time inside the failure branch only, so a
session connected past the window that then hit one failed refresh rendered
the pre-fix "Worktree list unavailable" — the exact case this PR exists for —
while identically aged counts still rendered unlabeled as live whenever the
refresh was merely pending. Age now decides live vs "Last known" and the
failure branch keeps whatever the host last proved; "Worktree list unavailable"
is reserved for a catalog that never loaded.

Header stats summed every entry ever cached, so removing a desktop left its
lifetime numbers in the total for the rest of the session. totalHomeStats now
sums the hosts still paired, which also covers removal from the host screen.

wireHostSubscriptions is the effect body moved verbatim out of useEffect;
react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one
and the changed-code gate has no working suppression path (an inline directive
reads as unused to the plugin-less scan).

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:26:02 -07:00
e59a319ffe fix(sidebar): keep each project's entry-point workspace visible under "Hide sleeping" (#12257)
"Hide sleeping" swept each project's main workspace out of the sidebar as soon as
it had no live PTY, browser tab or agent — even with "Hide default branch" off.
For a project whose only row is that workspace (a folder workspace, a fresh
clone, a detached-HEAD main), the entire project vanished with no in-place way
back.

Adds a shared `isSleepingSweepExemptWorkspace` predicate keyed on
`isMainWorktree` rather than the branch name, so folder workspaces (no branch),
detached-HEAD mains, and SSH rows whose head/branch are blanked while a provider
is disconnected all stay put. Wired into `computeVisibleWorktreeIds` (sidebar,
Cmd+1-9, workspace board), the jump palette's duplicate inline pass, and mobile's
`filterWorktrees`.

Ships default-on with an escape hatch: a persisted
`alwaysShowDefaultBranchWorkspace` setting surfaced as "Except default branch"
under "Hide sleeping". Explicit "Hide default branch" still wins, since it
filters before the sleeping sweep.

Mobile reads the setting but never writes it back, so a desktop opt-out can't be
clobbered by a filter tap before the ui.get roundtrip lands.

Combines the two PRs open against #8873. #8966's exempt set is a strict subset of
this one, so its production diff was subsumed rather than ported; its jump-palette
render harness and e2e spec were carried over, and are the only such coverage here.

Fixes #8873
Closes #8966

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:55:17 -07:00
Brennan Benson 3d68212f7b fix(mobile): surface worktree catalog failures instead of silent 0 worktrees (STA-3123) (#12235)
* fix(mobile): surface worktree catalog failures instead of showing 0 worktrees (STA-3123)

A connected host whose worktree.ps request fails now shows an explicit
catalog-failure state (with the RPC error code) on the host page, and
'Worktree list unavailable' on the home host card, instead of silently
rendering as a healthy host with zero workspaces.

* fix(mobile): mark cached worktree catalogs unavailable
2026-08-03 00:48:30 -07:00
Brennan Benson 169ec8f08d fix(mobile): refresh folder workspace catalog (#11767) 2026-08-01 01:42:27 -07:00
NeilandBrennan Benson 6e7ceafd07 perf(mobile): avoid unchanged worktree catalog payloads (#11735)
* perf(mobile): avoid unchanged worktree catalog payloads

* fix(mobile): isolate catalog snapshots by limit

* review: reassert host truth on unchanged polls; content-address snapshots

Client — the `changed` gate meant an unchanged poll skipped setWorktrees /
setLastKnownWorktrees / setCachedWorktrees, so optimistic local edits
(togglePin, handleDeleteWorktree's failure re-add) and the #8498 cache guard
were no longer repaired while the host catalog was stable. The gate bought
nothing: setCachedWorktrees is an in-memory Map write and areWorktreeListsEqual
already ran every poll, so the steady state still short-circuits on array
identity. All wire savings are unaffected. admit() now just returns the
confirmed rows and HostScreen applies them exactly as it did pre-PR.

Also on the client:
- a stale response from a superseded client/host no longer clears the token the
  current client/host just established
- discriminate on `worktrees` rather than on `'unchanged' in response`, so a
  future catalog field named `unchanged` can't reclassify a full response
- useRef over useMemo for the snapshot client; React may discard memoized values
- hoist WORKTREE_PS_FULL_LIMIT so the truncates-at-200 rationale travels with it

Host — replace the per-limit snapshot cache with a content-addressed id (ETag
semantics). Ownership lives in the id, so concurrent clients, differing limits,
and runtime restarts are correct by construction; this drops the LRU, the
eviction policy, the per-runtime WeakMap, and the retention of up to 8 full
catalogs. The remaining cache is a pure memo: because ids derive from content,
dropping or thrashing it costs CPU and nothing else. Keeping the memo also
keeps the measured steady-state cost — hashing every poll instead measured
2.24ms vs 0.75ms for the compare on a 310KB catalog.

Verified: mobile 2784 passed / 3 skipped, src/main/runtime/rpc 1064 passed,
node + mobile typechecks, oxlint, oxfmt, max-lines ratchet.

* fix(runtime): isolate catalog snapshot memo

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-31 23:12:13 -07:00
NeilandOrca aab112933e Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai>
2026-07-23 18:35:31 -07:00
Neil 8f40ddf328 fix(memory): bound OOM-prone accumulators (#10179) 2026-07-23 06:22:56 -07:00
NeilandOrca 4468d54f3c perf(mobile): gate host polling on foreground/background (#9857)
* perf(mobile): gate host polling on foreground

The mobile host screen ran two 3s polls (routed + embedded), each firing worktree.ps
AND repo.list, with no foreground/background gate — so a connected phone kept pinging
every 3s (worktree.ps is a full multi-repo process scan) plus a radio wakeup, including
brief background windows while the socket stays parked.

Consolidate both into one startHostWorktreeRefresh lifecycle and AppState-gate the
interval so BOTH polls stop while backgrounded and refresh immediately on foreground
return. worktree.ps keeps its 3s cadence while foregrounded (it carries live agent
status/preview/unread that no push event replaces). repo.list stays on the interval as
an AppState-gated, self-throttling (REPO_METADATA_REFRESH_MS=60s) convergence safety-net
— desktop Settings repo edits notify only the renderer, not the runtime clientEvents
stream, so it can't be made purely event-driven without going stale — and additionally
gets a reposChanged/worktreesChanged fast-path and reconnect-replay refetch.

Verified in a deps-installed mobile checkout: full mobile suite 2232 pass, typecheck,
oxlint (within the frozen max-lines budget), and oxfmt --check all clean.

Co-authored-by: Orca <help@stably.ai>

* chore(mobile): drop stale fetchRepoMetadata dep from the reconnect effect

Address CodeRabbit nitpick: the reconnect effect no longer calls fetchRepoMetadata
(that refetch moved into startHostWorktreeRefresh), so it shouldn't remain in the
effect's dependency array.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-22 14:12:55 -07:00
OrcaWin 05c32c4757 fix(runtime): isolate navigation across paired clients (#9664) 2026-07-20 21:36:15 -07:00
Brennan Benson e3c8d96638 Access the Floating Workspace from mobile (#8405) (#9523)
* Access the Floating Workspace from mobile (#8405)

Surface the desktop Floating Workspace (the global, repo-less scratchpad
of terminal tabs under the synthetic `global-floating-terminal` id) on the
mobile app so a Claude session left running there is reachable from a phone.

Adds a terminal-icon button to the mobile host header (phone + tablet
sidebar) that opens the existing Session screen for the floating id. The
sentinel already had host-side RPC support (#5946: local runtime, homedir
cwd, explicit-id fast paths in session.tabs.*); this wires up the mobile
surface and gates it on a new `floatingWorkspaceEnabled` status flag so the
entry hides on hosts that predate it or where the feature is disabled.

The Session screen learns an `isFloatingWorkspaceRoute` flag (mirroring the
existing `folder:` route pattern) that hides repo-backed surfaces — Files,
Source Control, PR/checks, agent history — skips the diff-comment and GitHub
probes, routes terminal URL taps to the phone browser, and limits the New
Tab drawer to terminals + agents (browser/markdown creation resolves a real
worktree host-side and stays desktop-only). useLiveWorktreeName
short-circuits for the sentinel so it no longer polls worktree.show forever.

Extracted the host status.get gating into a useHostStatusGates hook to keep
the host screen under the max-lines ratchet.

* Harden mobile Floating Workspace routing

* Fix mobile host gate reuse race

* Harden floating mobile session polling

* fix(mobile): harden floating workspace route reuse

* fix(mobile): skip floating workspace repo lookup

* fix(mobile): clarify floating workspace header action
2026-07-20 21:05:40 -07:00
8f6e44ed53 Show agent session history on mobile (#6786)
* Show agent session history on mobile

Bring the desktop "Agent Session History" panel to Orca Mobile as a
per-worktree screen: browse past agent transcript sessions across the
host with scope tabs (Workspace/Project/All), search, grouping, session
cards, and tap-to-read message previews.

The transcript scan previously ran only over Electron IPC, so mobile
could not reach it. Expose it over the runtime RPC protocol mobile
already speaks (aiVault.listSessions) so the scan runs on whichever host
owns the transcripts — correct for local and SSH/remote hosts. Both the
desktop IPC handler and the new RPC method share one cache, so opening
the desktop panel and the mobile screen never double-scan.

The pure filter/group/display logic is lifted into /shared (the renderer
re-exports it) so the standalone mobile package can reuse it. Mobile
narrows scoped tabs client-side by cwd path-prefix because the host scan
treats scope paths as a widening union.

Resume-from-mobile is intentionally a follow-up.

* Fix mobile agent history list rendering and RPC authorization

- Authorize aiVault.listSessions in the mobile RPC allowlist so the
  mobile client's call is not rejected before dispatch (without this the
  screen could never load sessions at runtime).
- Name each SectionList section's rows `data` (the field React Native
  reads) instead of `cards`, fixing a type error and silent empty-section
  rendering.

* Address review feedback on agent session history

- Match quoted repo:/path: search operator values so labels and paths
  with spaces match (e.g. path:"/Users/ada/My Project").
- Hold a scoped tab in loading until the worktree list resolves instead
  of firing an unscoped fetch that briefly shows unrelated host history;
  proceed once loaded even if the worktree is absent (no stuck spinner).
- Clear cached host capabilities on disconnect/host-switch and failed
  status.get so a capability-gated action can't linger for a host that
  doesn't support it.
- Cover the real OrcaRuntimeService codex-home forwarding path and the
  quoted-operator parser with tests.

* Hide redundant mobile current worktree badges

Co-authored-by: Orca <help@stably.ai>

* Resume agent sessions from mobile history (#6969)

Co-authored-by: Orca <help@stably.ai>

* Adapt merged seams to main's lint and reply-sender hardening

Co-authored-by: Orca <help@stably.ai>

* Cap mobile project-scope paths to the aiVault RPC bound

Co-authored-by: Orca <help@stably.ai>

* Share the aiVault scopePaths bound between the RPC schema and mobile

Co-authored-by: Orca <help@stably.ai>

* Guard shared AI Vault inflight cleanup against concurrent key replacement

The extracted cache module's .finally() cleared inflight tracking
unconditionally, dropping the if (inflightKey === key) guard its sibling
outer cache kept: an older scan resolving after a different-key scan
replaced the tracking would null the newer scan's dedup slot, so a
re-request started a duplicate transcript rescan. Mirrors the sibling
guard; the regression test flushes a macrotask so a reverted guard fails
fast on the call count instead of hanging.

Co-authored-by: Orca <help@stably.ai>

* Harden aiVault.listSessions contract and gate mobile header entry on capability

- Clamp scopePaths (64) instead of rejecting, cap limit at 2000, and make
  executionHostId optional so mobile can omit it; restamp per caller.
- Retain successful mobile terminal-create mutation ids for 60s so resume
  retries dedupe after transient socket drops.
- Gate the session-header Agent History action on the aiVault.v1 capability
  (mirrors the host-list action) so old hosts never show a dead-end entry.
- Fix stale contract comments (scopePaths clamp semantics; filters move
  includes quoted repo:/path: operator parsing).

* Add subagent field to session test fixtures after #7423 merge

AiVaultSession.subagent became required on main; the five fixtures added on
this branch predate it. Top-level scanned sessions carry null.

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
2026-07-10 13:48:10 -07:00
Jinjing 44d5ed0439 1.4.131 rc2 release prep (#8020)
* Support WSL Codex settings promotion and harden config write-back

- Enable settings promotion for WSL runtimes using per-distro baselines.
- Create parent directories if missing to prevent promotion ENOENTs.
- Keep restrictive permissions (0600) and follow symlinks on promote.
- Respect CRLF line endings when inserting keys into CRLF config files.
- Skip redundant baseline file writes when settings are unchanged.
- Include the release scan report for the 1.4.131-rc2 prep.

* Refactor sleeping agent wake flow and fetch rate limits via backend

- Background-mount only targeted terminal tabs during passive wake to
  prevent spawning unnecessary PTYs for unvisited tabs.
- Latch edge-triggered wake requests that arrive mid-hibernation and
  track active claims to prevent double-resuming a provider session.
- Query the ChatGPT wham usage backend API directly with fetch for
  rate limits, avoiding launching Codex or WSL login shells.
- Asynchronously probe and serialize WSL auth files with timeouts to
  prevent synchronous I/O from stalling Electron's main process.
- Fix config promotion edge cases such as missing parent directories,
  dangling symlinks, and atomic write permission widening.

* Support WSL dotfile-symlink write-back and lengthen redeem timeout

- Preserve symlinked Codex config on WSL by writing through the
  existing file instead of atomic-rename, since \\wsl$ symlink
  metadata isn't reliably detected and rename would clobber the link.
- Tighten new ~/.codex directory creation to 0700 (holds auth.json).
- Give explicit reset-credit redemption a 30s backend timeout instead
  of the 10s background-poll default, since it's user-triggered.
- Read sleeping-agent session state from the worktree's actual
  execution-host partition instead of always the local one, so the
  headless-wake check works correctly for SSH-hosted worktrees.
- Isolate serve-sim watcher tests from the real $TMPDIR/serve-sim
  state file to avoid leaking unrelated events.
2026-07-09 21:54:22 -07:00
JinjingandOrca 430d4b9482 Show all worktrees across all hosts on mobile (#7500)
* Show all worktrees across all hosts on mobile

Avoid honoring desktop's host-filtering settings since mobile lacks the
UI to manage or unhide them. This prevents worktrees from being silently
hidden under certain host scopes.

Additionally, this removes worktree filtering based on repo metadata, which
previously caused worktrees to vanish when same-named repos on different
hosts collapsed to a single ID.

* fix(daemon): preserve promisify.custom type through wrapChildProcessApi

The windows-hidden-console-children test (from #7499, admin-merged with a
failing verify) failed tsgo: promisify(wrapped) resolved to its zero-arg
overload because the wrapper erased its argument to a bare variadic function
and the fake never statically carried promisify.custom. Preserve the wrapped
type via a generic overload (accurate: the wrapper copies the call signature
and symbols verbatim) and build the fake as a real CustomPromisify, so
promisify routes through the custom overload as it does in production.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-05 23:10:48 -07:00
Brennan BensonandOrca 5fd9f0c62c Prevent mobile worktree selection from focusing desktop (#6461)
Co-authored-by: Orca <help@stably.ai>
2026-06-26 19:34:33 -07:00
Brennan BensonandOrca c0fd54c341 Show Agent activity for mobile smart sort (#6347)
Co-authored-by: Orca <help@stably.ai>
2026-06-25 19:00:35 -07:00
0c0367ea88 feat(agents): native Xiaomi MiMo Code support (#6239)
* feat(agents): native Xiaomi MiMo Code support

Add mimo-code TUI agent (detect mimo, --prompt, --session resume).
Inject MIMOCODE_HOME overlay and /hook/mimo-code status plugin on mimo
launch when agent status hooks are enabled; restore via shell-ready
wrappers. Reuse OpenCode-family hook normalization in the listener.

SSH remote MiMo hook overlays are not included (local/daemon first).

Closes #6220

* fix(mimo): remirror overlay config idempotently

rmSync overlay config before mirror so a second mimo launch does not
hit EEXIST in mirrorEntry and fall back to the user MIMOCODE_HOME.

* review: harden mimo code support

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Doan Bac Tam <24356000+doanbactam@users.noreply.github.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-06-24 00:17:49 -07:00
Jinwoo HongandOrca 5d8617bc71 Fix mobile workspace parity (#6207)
Co-authored-by: Orca <help@stably.ai>
2026-06-23 17:07:28 -07:00
Jinwoo HongandOrca 32e6ab7655 Fix mobile workspace list parity (#6001)
Co-authored-by: Orca <help@stably.ai>
2026-06-21 15:11:55 -07:00
Jinjing 27bf41793c Select last visited worktree repo by default on mobile (#5912)
- Resolve the initial repository in the mobile NewWorktreeModal using
  the last-visited worktree's repo ID loaded from AsyncStorage.
- Extract and share new-workspace dialog repository selection and
  resolution logic to src/shared, mirroring it on mobile to bypass
  Metro bundling limits.
- Add unit tests for last-visited repo reading and selection resolution.
2026-06-20 15:12:20 -07:00
Jinwoo HongandOrca 06d674b35d Fix mobile folder workspace visibility (#5679)
Co-authored-by: Orca <help@stably.ai>
2026-06-17 23:18:34 -07:00
c9bd61376f feat(mobile): combine PR sidebar and checks parity (#5641)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
2026-06-17 22:52:12 -07:00
Jinwoo HongandOrca e3bf7d8614 feat(mobile): pickers, workspace parity, active-workspace focus, tap-to-open, source-control parity, artifact viewing (#5330)
Co-authored-by: Orca <help@stably.ai>
2026-06-15 22:02:51 -07:00