Commit Graph
715 Commits
Author SHA1 Message Date
NeilandClaude 1d2c0e5879 perf(mobile): coalesce stalled connection log persistence (#22973)
* perf(mobile): coalesce stalled connection log persistence

* fix(mobile): bound connection-log writes and flush the log before the app suspends

The coalescing pass counted a pending persistence attempt per append and then
burned that whole budget on unblock. With failing storage, 500 appends during a
stall released ~1000 back-to-back `setItem` calls — and slow storage and failing
storage are the same device condition, so the amplification fired in exactly the
scenario the coalescing was for.

The counter is gone. A single `dirtyHosts` flag replaces it: the host's revision
always holds the newest entries, so counting appends bought nothing but writes.
The loop re-reads the revision after each save, so a stall costs the in-flight
snapshot plus one attempt at the newest one, whatever the append count. That also
retires the compound `finally` condition and its unreachable `(… ?? 1) - 1`.

A failed snapshot no longer gets an immediate second `setItem` against a store
that just rejected. It gets one retry after 200 ms, and none at all once a newer
snapshot is queued, because that snapshot already carries the same entries.

`flush()` closes a data-loss gap that predates the coalescing: a write that
failed was only retried by the next append, so when the disconnect was the last
thing to happen the entries explaining it never reached storage. It drains the
in-flight save and makes one more attempt at the newest snapshot, wired to
AppState `background` the way `subscribeConnectionRevivalTriggers` wires resume.

Two revisions tests asserted the retry budget as intended behaviour (6 writes
for 3 appends; 3 for one failure) and now assert one write per snapshot
generation instead. `connection-log-buffer.test.ts` is untouched, including its
requirement that one transient failure self-heals without another append — that
is what the single delayed retry keeps.

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

* fix(mobile): type the background-flush test mock so the tests ratchet passes

The new test copied `ReturnType<typeof vi.fn>` from
connection-revival-triggers.test.ts, which is grandfathered in the
tests-typecheck baseline for that exact TS2345. The ratchet only shrinks,
so type the mock instead of adding a baseline entry.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-26 20:59:10 -07:00
457a84d5a1 fix(mobile): label direct paths with the shared Tailscale check (#23039)
directPathForEndpoint hand-rolled Tailscale detection that called any 100.x
address a tailnet and missed Tailscale IPv6, so the same endpoint could be
labelled differently from the direct connection log. It now uses the shared
isTailscaleEndpoint. Also: a stale no-relay comment, a duplicate routing doc
line, and the overlay chain settling to Promise<void>.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-26 01:05:55 -04:00
d06b43e634 fix(tasks): read a malformed saved Linear team selection as sticky-all instead of crashing the page (#22279)
* fix(tasks): read a malformed saved Linear team selection as sticky-all instead of crashing the page

A persisted defaultLinearTeamSelection that is not a string array (a string
reached 1.4.207, report 0a2b6e7f) threw '(t ?? []).filter is not a function'
inside a commit-phase effect and tripped the page.tasks error boundary. The
value is now normalized where the page reads it and where a host projects it
to paired clients; anything but a string array means sticky-all.

* fix(mobile): read a malformed saved Linear team selection as sticky-all

A host that predates the desktop fix projects its raw store value, so the
mobile Linear list must tolerate the same string shape. Also trims the
desktop helper's comments to the why.

* fix(tasks): validate projected Linear team IDs and refresh parity contract

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-25 21:01:32 -07:00
Jinwoo Hong ce2f75e172 refactor(mobile): the live input's composing range comes from a platform seam pair (#23037)
#22958 made the page on Android report no composing range with a user-agent check inside the shared
live-input hook. Host facts live in a `src/platform` pair, so the check moves to
`live-input-composing-range.web.ts`; the native file passes the event's range through. Behaviour is
unchanged. The hook test mocks the seam; the user-agent cases move to the seam's own test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 23:41:46 -04:00
77bc0d77d8 fix(editor): highlight scoped dotenv filenames on desktop and mobile (#22416)
Co-authored-by: willydallas <willy.dallas@pm.me>
Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
2026-09-25 20:40:53 -07:00
02a5594434 fix(mobile): give each host field one writer so relay routing can't revert edits (#22956)
* fix(mobile): give each host field one writer so relay routing can't revert edits

Relay learners (director re-resolution, credential rotation, direct-to-relay
upgrade) saved the whole HostProfile snapshot their connection opened with,
so a late relay write reverted an Edit Host endpoint and rewrote the device
token. The relay overlay also stored a copy of the paired address, which the
direct probe kept dialling after an edit.

Pairing is now the only full-profile writer (savePairedHost, fenced by a
census). Learners call setRelayRouting(hostId, relay), which never touches the
row or keychain, refuses a removed host, and skips unchanged routing. The
overlay is relay-only in memory; one serializer writes the v2 shape older
builds parse, without the direct entry. Saving a new endpoint rebuilds even a
Relay-active client from the saved row.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* test(mobile): re-record RPC goldens for relay-only host routing

Baseline moves to the product commit. adapterSha256 moves for the pairing and
relay-credential adapters, which now read relay.relayHostId and inject
saveRelayRouting. Only the four direct-upgrade bodies change: the settled host
no longer carries the overlay's endpoints copy (direct-primary, relay-primary)
or the duplicate relayHostId; relay and every effect are unchanged.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* refactor(mobile): relay learners own only relay routing

Follow-up to the field-ownership split. The supervisor keeps its host
read-only and holds the relay in a small owner that persists moves through
setRelayRouting; the direct upgrade returns { relay, bundle } and the
lifecycle composes the profile once. With one direct endpoint left, the probe
takes a bound openDirect and the lifecycle computes the direct path once.

One name per writer: setRelayRouting and savePairedHost are also the
dependency keys, and the removed-host error is RelayRoutingHostRemovedError.
The census now counts real value imports of savePairedHost, not mentions.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* test(mobile): re-record RPC goldens for the { relay, bundle } upgrade result

Baseline moves to the refactor commit, and adapterSha256 moves for the
pairing and relay-credential adapters (dependency keys renamed to
savePairedHost and setRelayRouting). Only the four direct-upgrade bodies
change: the settled upgrade result is now { relay, bundle } instead of
{ host, bundle }, with identical relay, bundle, state and effects.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* refactor(mobile): refresh edited hosts and let the establisher own relay

Edit Host now reuses refreshHostClient, which already rebuilds an owned
client (relay-active included) from the saved row after re-pairing, instead
of a savedAddressChanged flag on forceReconnect.

The session establisher, the only relay dialer, holds the relay and adopts
learned routing through setRelayRouting; the supervisor takes a host id and a
required relay, so the no-relay guards and the synthetic upgraded profile go.
enqueueHostListMutation returns its operation's value, and the overlay store
names its API after routing.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

---------

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-25 23:15:12 -04:00
Neil fa018fe520 chore(deps): refresh maintained dependencies (#22964)
* chore(deps): refresh maintained dependencies

* fix(deps): defer upgrades that violate runtime and test contracts

* test: align catalog response assertion and updated formatter
2026-09-25 19:04:12 -07:00
8846987c99 feat(rate-limits): add Cursor usage tracking (#22633)
* feat(rate-limits): add Cursor usage tracking

## ELI5

If you use Cursor, Orca now shows how much of your monthly Cursor plan you
have used, next to the Claude, Codex and Grok meters, and in Settings →
Accounts. It reads the sign-in Cursor already saved on this computer and never
changes it.

## What changed

Cursor becomes a rate-limit provider like Grok: a status-bar meter (default-on,
with its own toggle), a row in the usage roster, and a Settings → Accounts
section naming the signed-in account.

The credential is read from whichever of three stores has it, first match wins,
all read-only:

- the macOS login keychain item `cursor-access-token` / `cursor-user`, which is
  where `cursor-agent` 2026.06+ keeps the session;
- `~/.cursor/auth.json` and its platform variants, used by older CLIs;
- the Cursor IDE's `state.vscdb` (`cursorAuth/accessToken`), for people who
  never run the CLI.

The keychain entry is the one current CLIs use, and reading only `auth.json`
finds nothing on an up-to-date macOS install. A locked keychain cannot mask a
readable `auth.json`, and a locked `state.vscdb` cannot mask either.
`~/.cursor/cli-config.json` supplies the account's email and display name; it
never holds a token.

Usage comes from the dashboard route the Cursor web dashboard itself reads,
because Cursor documents no individual-user usage API — every documented API is
team- or Enterprise-scoped. Per Cursor's pricing docs an individual plan has two
pools, Cursor Models and Other Models, both resetting with the billing cycle,
plus optional on-demand spend; each becomes a named bucket. The headline
percentage prefers `used / limit` over the sibling percentage fields, which are
pre-rounded for the dashboard's own copy. Because the route is undocumented the
mapping is defensive: an unrecognised payload resolves to `unavailable` and
hides the bar rather than publishing a zero that reads as "no usage".

Orca never runs `cursor-agent login` and never writes, refreshes or rotates a
Cursor credential. An expired token short-circuits to an actionable
"run cursor-agent login" instead of spending a request that can only 401 — not a
rare case, since `cursor-agent status` still reports `isAuthenticated: true`
against a token that expired months ago.

## Why this shape

Six open PRs implement this feature and none reads the keychain, so each finds
nothing for a large share of users; this takes the auth layer further and keeps
what those PRs verified live. The bar is not gated on `cursor-agent` being on
PATH, unlike other CLI providers, because an IDE-only session is real usage with
no CLI to detect.

`readKeychainPassword` moved out of the Claude keychain reader into
`src/main/macos-keychain/generic-password.ts` so both providers share one
`security(1)` wrapper. It is a byte-for-byte relocation, so Claude's credential
path is unchanged; the two child_process allowlists move the entry with it and
neither ratchet count changes.

Co-authored-by: Preschian Febryantara <preschian@users.noreply.github.com>
Co-authored-by: Qwesdy <qwezdi@proton.me>
Co-authored-by: ivo922 <github.concur614@passmail.net>
Co-authored-by: Mihail Vratchanski <mivrkiki@gmail.com>
Co-authored-by: Tauri-EPO <enrico.pin@gmail.com>
Co-authored-by: Raajik <44516546+Raajik@users.noreply.github.com>

* test(rate-limits): name the JWT helper's segment type in the Cursor tests

The anti-slop gate rejects a bare `object` parameter; the fixtures build a
claims record, so say that.

* fix(rate-limits): render Cursor's pools and keep its plan total visible

Review of the first commit found the meter effectively blank for a healthy
account, which the screenshots missed because the only Cursor session on hand
had expired and never reached the success path.

- The verbose status-bar segment filtered buckets through an allowlist written
  for Gemini's experimental models, so both Cursor pools were dropped and the
  fallback needed a `session` window Cursor never reports. A signed-in account
  rendered an icon and no number. The allowlist now admits Cursor's pools, and
  the fallback accepts a monthly window.
- `getWindowSections` dropped `monthly` whenever buckets existed. Cursor puts
  the plan total there and its sub-pools in buckets, so a plan at 92% showed as
  50% in the roster, the tooltip, and the tightest-usage pick.
- A plan reporting `enabled: false` still published its 0% pools, painting a
  healthy meter for a pool the account does not own and skipping the
  request-quota fallback.
- `redirect: 'error'` turned the dashboard's bounce to /login into a generic
  network failure, hiding the actionable sign-in message.
- A busy `state.vscdb` (the IDE holds it open) surfaced as a provider error,
  which would pin an alert bar on Cursor IDE users who never set Cursor up in
  Orca. It falls through to "no credential" instead.
- Refreshing the Accounts section read the keychain twice for one update.

* fix(rate-limits): pin the platform in the Cursor keychain tests

Review caught three cases that assumed macOS: the keychain source is behind an
explicit `process.platform` check, so on the Linux CI runner the mocked read was
never reached and the tests read the CLI file instead. They now set the platform
they mean, and two new cases assert the off-macOS fall-through.

Also track the credentials reference doc (docs/** is ignored by default, so a
new reference needs its own allowlist entry) and give the visibility fixtures
their own provider id instead of Grok's.

* fix(rate-limits): prefer a live Cursor session and report a failed refresh

Review round two, from CodeRabbit and Pullfrog.

- Credential precedence returned the first token that parsed, so an expired
  keychain token in front of a fresh Cursor IDE session reported "sign-in
  expired" on every poll while a usable session sat one source below. A live
  session now wins; the expired one is returned only when nothing live exists,
  so the actionable message still appears in that case.
- The usage schema took `.optional()` where the route sends `null` for an absent
  sub-object, so one null pool failed the parse for the whole body and threw
  away valid pools and the billing cycle with it.
- Cursor usage could survive an account switch: a failed refresh for account B
  kept account A's figures beside B's name in Accounts. The snapshot now carries
  a hashed account fingerprint, and a known-and-changed identity clears the
  previous reading. A refresh that names no account still keeps its own.
- The Accounts section rendered nothing at all when a signed-in account's fetch
  failed, and could repaint an older account when two status reads overlapped.
  It now states the failure — beside the numbers when a stale snapshot remains —
  and ignores superseded reads.
- A web client claimed "not signed in" for a host it cannot read, contradicting
  the meter beside it; it now says the detail is host-only.
- Signed-out copy named `cursor-agent login` as the only way in, though an IDE
  sign-in works just as well.
- The census comment ended at 4219 after the pacer squash without naming the two
  modules #22616 added; recorded them, re-measured on a clean origin/main.
- Narrowed the docs claim: Cursor documents all-plan APIs, but no individual
  usage endpoint.

* fix(i18n): localize the web client's Cursor host-only notice

It reaches the Accounts pane like any other string, so the coverage gate is
right to want it in the catalog rather than allowlisted.

* fix(rate-limits): name the Cursor account on failed refreshes, and ship the reworded copy

Review round three. Both findings say an earlier fix did not actually take.

- The account-switch guard reads `authProvenance` off the fresh result, but the
  fetcher stamped it only on success and network failures. The `stale-token`,
  429, 5xx and parse results omitted it, and so did the expired-session branch —
  so a switch whose first refresh failed, which is precisely the case the guard
  exists for, still rendered the previous account's figures under the new name.
  Every failure holding a readable session now names its account; a missing or
  unreadable credential still names none. The service test also fed a result
  shape the fetcher never produces, so it proved nothing; it now uses the real
  stale-token shape, and the fetcher test asserts provenance across 401/429/5xx
  and expiry.
- The reworded signed-out copy never rendered: a present catalog value beats the
  `translate()` fallback, and `sync:localization-catalog` only adds missing keys
  rather than updating changed defaults. Updated both strings in en.json, which
  also prunes them from the runtime-required catalog now that they match.

* docs: keep the Cursor credentials reference out of the tree

Its content lives in the PR description instead; docs/** stays ignored rather
than gaining an allowlist entry for this branch.

* test(mobile): drop the census note main no longer pins

main removed `SESSION_ROUTE_MODULES` and re-pinned this lane on a different
count, so the paragraph this branch added documents a number series that is
gone. The branch touches nothing in this file now.

---------

Co-authored-by: Preschian Febryantara <preschian@users.noreply.github.com>
Co-authored-by: Qwesdy <qwezdi@proton.me>
Co-authored-by: ivo922 <github.concur614@passmail.net>
Co-authored-by: Mihail Vratchanski <mivrkiki@gmail.com>
Co-authored-by: Tauri-EPO <enrico.pin@gmail.com>
Co-authored-by: Raajik <44516546+Raajik@users.noreply.github.com>
2026-09-25 18:54:11 -07:00
Jinwoo Hong 77829142ac fix(mobile): a terminal opens once at the phone's size, on the page and natively (OTA phase C follow-up) (#22960)
* refactor(mobile): extract the once-per-route terminal viewport measure

Behaviour-preserving move of measureViewportOnce into a pure module so the
first-subscribe ordering can be tested without mounting the session route.

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

* fix(mobile): send phone dims on a terminal's first subscribe

A fresh terminal document has no xterm until its first init, so the viewport
measure could not answer before the first subscribe. The subscribe went out
without dims, the host serialized the snapshot at the desktop's size, the
phone painted it, measured, resubscribed and replayed the whole snapshot a
second time at the phone's size.

The first subscribe of each document now opens an empty terminal, measures,
and only then subscribes, so the host fits the PTY before serializing and the
phone paints once. Measure failure still subscribes without dims and the
existing fit pass takes over.

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

* fix(mobile): converge the fit pass on the viewport the subscribe sent

A frame-height refit can clear the measured flag after the first subscribe
has already carried the phone's viewport. The fit pass then treated the
snapshot as dimensionless and resubscribed, replaying the whole snapshot a
second time at the same size. It now judges convergence against the
viewport that subscribe actually sent; the refit still owns real layout
changes through updateViewport.

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

* test(mobile): reject the failing measure without an async wrapper

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

* fix(mobile): keep the fit-pass decision on the measured flag

Converging on the viewport a subscribe carried accepted a stale one: after
native chat covered the terminal through a rotate or dock, the return
resubscribe sent the old dims, the host matched them and the pass never
re-measured (#4579). The decision is back on the measured flag; the sent
viewport only lets a matching fresh measure skip the round trip.

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

* fix(mobile): forget a document's first-subscribe mark only on web-ready

The terminal handle re-attaches null then new on every theme or text-size
change, and clearing the mark there let the next resubscribe post an empty
init onto a live document. web-ready is the one signal that a new document
started, so the mark is dropped there instead.

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

* test(mobile): pin each held-subscribe teardown guard on its own

Splits the teardown test so dropping either the generation check or the
in-flight check alone fails a test, and shares one MutableRef type.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 21:02:10 -04:00
Jinwoo Hong 570f070f6c fix(mobile): the page's Live input on Android echoes each letter as typed (OTA phase C follow-up) (#22958)
* test(mobile): the page's live input sends a composed word's letters as they are typed

The reported shape, in a browser: `/tui` on an Android keyboard, where the `/` reached the
terminal and `tui` did not until Enter. Android keyboards hold a composing region over the Latin
word being typed, so on the page every input event mid-word carries `isComposing: true`, and the
preedit mirror holds reported preedit with no settle timer. Native Android reports no range, so
there each ASCII keystroke is sent as it is typed.

Driven through Chromium's own IME path with an Android WebView user agent: the letters after the
slash must arrive one by one, and a correction on commit must still erase and retype. The iPhone
case is the guard that a composition off Android stays held. The hook case says the same thing
without a browser.

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

* fix(mobile): the page on Android reports no composing range, as native Android does

`handleLiveInputChange` passed `nativeEvent.isComposing` straight to the preedit mirror. On the page
that event is the DOM's, and an Android WebView marks the keyboard's composing region, which Samsung
and other Latin keyboards keep over every word. The mirror treats a reported range as preedit that
is not text yet, holds all of it with no settle timer, and Chromium fires no input event after
compositionend, so the word sat in the field until the next keystroke or Enter.

Native Android reports no range at all, and its fallback holds only a trailing non-ASCII run. The
page on Android now reports the same: undefined. ASCII echoes on each key, a Hangul or kana run
still settles on the timer, and a correction on commit still erases and retypes. iOS, where the
range is the text system's marked text, is unchanged.

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

* docs(mobile): name the Android page among the platforms that report no composing range

The mirror's fallback note listed only React Native Android; the page on Android now reports no
range too. The reader's docblock becomes the one line it needs.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 20:24:54 -04:00
Jinjing f59ee6c859 Simplify .map().flat() to .flatMap() in onboarding tests (#22917)
Replace the two-method chain with the equivalent idiomatic flatMap method for clearer, more concise code.
2026-09-25 12:41:04 -07:00
Brennan BensonandClaude d443320af2 refactor(native-chat): remove the unused terminal handoff (#22783)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

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

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

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

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

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

* test(native-chat): type the stalled snapshot drain without a cast

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

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

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

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

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

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

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

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

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

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

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

* test(native-chat): type the stand-in roster decoder without a cast

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

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 10:17:36 -07:00
Jinwoo Hong c9d421f3c1 fix(mobile): the page's terminal shows its last rows above the command dock (OTA phase C follow-up) (#22806)
* fix(mobile): mount the page's terminal frame with its onLayout

On the page every branch of the session content is a bare View in one
slot, so the terminal frame reused the loading View. react-native-web
observes onLayout only on a View that mounts with it, so the frame never
reported its height: every measure fell back to the page's
window.innerHeight and the PTY got rows the frame cannot show (63 vs 47
on a 1280x2856 screen), with the last rows under the command dock.

Keying the frame makes it mount fresh. The parity pin moves for the key
string.

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

* fix(mobile): size the terminal document to its host, not the window

Inside the WebView the window is the terminal frame; on the page it is
the whole page, header and dock included. Every fit, pan, scroll and
overlay bound in the document read window.innerWidth/innerHeight, so a
measure without a container height sized the PTY to the page.

The document now reads one seam, viewportSize: the window by default
(native unchanged), the host element's box on the page. A census keeps
raw window size reads out of document/.

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

* fix(mobile): put the terminal document's coordinates in its host

Round one moved the page document's size to its host but left its
origin at the window, so a tap below the session header mapped rows low
(row 19 for row 11), and the selection overlay and scroll indicator,
position: fixed, drew over the page.

- viewportRect replaces viewportSize: the host's box on the page, the
  window at 0,0 in the WebView. viewportPoint is the one place a client
  point meets that origin; cell mapping, mouse reports, the pinch anchor
  and edge scroll go through it, and a census holds the rest to deltas.
- observeViewport: the fit refits on a host ResizeObserver on the page,
  a window resize in the WebView.
- A hidden host measures 0x0; the fit keeps scale 1 rather than 0.
- On the page the host is the overlays' containing block.

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

* test(mobile): state what native measured in the parity note

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

* test(mobile): read the page terminal's overlay and refit off its host

The overlay is absolute inside the host now, and the page's refit
follows the host's box, so the owed-frame case pulses the host rather
than the window.

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

* fix(mobile): hold the terminal fit while its host has no width

computeFitScale answered 1 for a 0-wide host, but applyFitScale still
committed that fit as soon as the cell width was known, so a display:none
screen got a fit for no box. The attempt now leaves the fit pending until
the host reports a positive width, and observeViewport starts it again
once it does: one commit, never at scale 0.

A regression test pins edge scroll to the host's edges: with the host at
top 100 and height 600, the down band starts at client Y 660, not 560.

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

* fix(mobile): skip the terminal refit while its host has no box

react-native-screens hides the session with display:none when another
screen covers it, so the host's observer reports 0x0. The refit still
clamped pan and repainted, so coming back to the session lost the pan
the user left; native never refits on navigation. The refit now does
nothing until the host has a positive width, and the next real box
refits once.

Also untangles the seam-count sentence in document-host-seams.

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

* fix(mobile): refit the page terminal only when its box changes

Coming back to a session takes the host from 0x0 to its old box. The
observer fired, the refit committed, and the user's pan and zoom were
reset, where native keeps them because navigation never resizes its
WebView. The fit now remembers the box it was committed for, and the
refit acts only on a positive box different from that one.

The frame tests wait on the document's own frames instead of a timer,
and the hidden-host case asserts the zero-width read happened.

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

* fix(mobile): refit a shown terminal when a fit was asked for while hidden

The zero-width gate drops a fit requested while the host is hidden, and
the same-box skip then kept the stale fit when the host came back at its
old size: 80 columns at 390 px stayed at the 55-column scale, 0.945
instead of 0.65. Any fit request now forgets the last fitted box, and so
does a text-scale change that could not resize a hidden grid. A plain
hide and show asks for no fit, so it still keeps pan and zoom.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 06:03:37 -04:00
Neil 90801e2deb feat(agents): add first-class ZCode harness (#22464)
* feat(agents): add first-class ZCode harness

Add ZCode (Z.ai's `zcode` CLI) as a supervised Orca agent: managed lifecycle
hooks on local, SSH and Windows hosts; status, question and approval reporting;
synthetic status titles; session resume; orchestration worker launch options;
and desktop + mobile agent-picker registration.

Written against the newly open-sourced `zai-org/ZCode` (agent CLI 0.16.9), not
against a remembered screen:

- ZCode's hook runner writes a Claude-compatible stdin alias set, so it routes
  through the existing Claude-compatible vendor path while keeping its own
  identity in the sidebar.
- `PermissionRequest` fires only once the approval card is on screen and racing
  the user's answer, so it is proof the pane is blocked, not an auto-approval.
- ZCode's clarification tool is literally `AskUserQuestion` with Claude's
  questions/options shape, so Orca's question card renders it unchanged.
- ZCode's `hooks.enabled` defaults to false, which is why configured hooks were
  reported as never firing; the installer sets it.
- ZCode renames its own process to `zcode-cli`, so the expected foreground
  process cannot be the launch command or dispatch refuses the pane.
- ZCode emits no OSC title in any state and repaints its ASCII banner forever,
  so readiness comes from Orca's synthetic hook title and launch drafts wait on
  the composer box rather than on a quiet render window.

Three files crossed their max-lines limit, so each is split along a real seam:
command-line entrypoint parsing out of agent process recognition, skill
classification out of skill root discovery, and registry coverage out of the
remote hook installer tests.

Refs #10564

* fix(zcode): drop the session-option catalog and pin the orchestration contract

ZCode's CLI exposes no `--model` flag at all, and the session-option launch path
refuses to apply any option until a model id is chosen. A catalog therefore could
not deliver `--mode` per worker, and would have accepted `--model` only to drop
it silently. Take opencode's position instead: no catalog, so `worker-start
--model` is refused with a clear message and ZCode launches with the model from
its own config. `--mode` stays reachable through agent args, which is also how
the yolo default is applied.

Add a contract test covering the parts that make ZCode a usable worker:
dispatchable foreground process, stdin prompt delivery, the prompt staying out
of the launch command, and the composer-gated draft paste.

* refactor(zcode): reuse shared helpers and cut the harness down

No behaviour change; every ZCode test still passes.

- Use installer-utils' own `hookDefinitionHasManagedCommand` instead of
  re-walking a hook definition by hand, which also drops a local string reader.
- Share one `readZCodeEventMap` instead of keeping the same narrowing in both
  hook-settings and hook-config-json.
- Collapse five identical error returns into one `zcodeHookError` builder, and
  return early from the status branches instead of assigning through `let`.
- Split the event-to-status decision out of `normalizeZCodeEvent` into a pure
  `readZCodeTurn`, so the normalizer reads as decide-then-build and stops
  computing the tool name for events that never look at it.
- Take a script file name in `readManagedZCodeHookEvents` like its siblings,
  which removes a `Parameters<typeof …>` indirection at the call site.
- Drop the unused `ZCodeHookEvent` export and inline a single-use path helper.
- Correct a stale comment: ZCode's loader is a strict `JSON.parse`, so the
  in-place edit preserves key order and indentation, not comments.

* fix(zcode): address review — keep unmanaged event keys, correct comment, de-dupe README

- `removeZCodeManagedHooks` deleted any event key whose list ended up empty, so an
  unrelated `"Notification": []` the user wrote was removed as collateral whenever a
  managed hook elsewhere made the write happen. Only touch an event Orca actually
  owned something in; covered by a new regression test.
- The `isNewTurnEvent` comment claimed UserPromptSubmit was ZCode's only turn
  boundary while the expression below it also returned true for SessionStart. Say
  what the code does: SessionStart lands the idle boundary, UserPromptSubmit is the
  turn boundary (the Codex/Claude shape).
- ZCode appeared twice in the README's single agent-badge block; keep the
  local-icon entry the link checker validates and drop the favicon duplicate.

* docs(zcode): call out that the desktop bundle's CLI cannot open a session

From live testing on #22464: pointing `zcode` at the desktop app's bundled
`glm/zcode.cjs` installs Orca's hooks fine but then fails with
`Cannot find package '@zcode/tui'`, so the pane never opens a session. The
symptom reads as a broken harness when the CLI simply has no TUI. Say which
build to use and how to check before reporting a problem.

Reported-by: JWu527
2026-09-25 02:17:51 -07:00
Brennan Benson 8f7cbad07b feat(mobile): name the machine after pairing (#22104)
* feat(mobile): confirm host identity after pairing

* refactor(mobile): unify host descriptor state

* chore(i18n): translate the last-known host descriptor label

The remote-host row's "Last known" label shipped in English only. Every other locale now carries it, worded as each catalog already words "last known".

* fix(mobile): make the pairing naming step safe to abandon and show the machine live

Pairing:
- An unreadable status.get reply no longer strands the pairing race: the
  descriptor read ran inside the race's success handler and threw, so the
  candidate was never counted and a direct-only pairing sat on
  "Connecting..." until the timeout. The race now reads the status through
  a reader that returns null instead of throwing.
- A pending pairing is now a small state machine: a save in flight owns the
  outcome (Cancel, back, or unmount no longer clear the journal under it),
  a failed save stays pending and the naming screen shows the error with
  Save still available, and Cancel never rejects.
- When the desktop refuses relay provisioning, the journal is cleared before
  the naming step instead of at save, so an app kill on that screen no longer
  blocks every later scan with "recovery pending".
- A pairing that resolves after the screen went away is cancelled rather
  than left with its journal.
- The screens keep their root ref callbacks stable (the latest pending
  pairing is read from a ref) instead of re-creating them per pairing.
- The label field's placeholder shows the name that an empty label saves.
- "Is this an existing host" is derived from the id identity resolution
  hands back, so host-store and its tests go back to main's shape.

Machine descriptor:
- Mobile keeps the host-reported machine name and OS in memory only, filled
  by the status reads that already happen, and labels it "Last known" from the
  row's own connection state. This drops the per-host AsyncStorage copy, its
  web sibling and web-overrides entry, and the removal cleanup. It also fixes
  a latch: freshness used to stay true for the whole process once a host
  had answered.
- Desktop reads the descriptor through lastVerifiedRuntimeStatus and marks
  it "Last known" using the same reachability verdict as the row's dot.
- Drops the unused hostname/previousPlatform resolver inputs and moves the
  "OS · machine" formatting into the shared resolver.

Also restores main's page-only Reconnect gate in the host header (#22326),
undoes the no-op toStoredHostProfile reformat, and re-measures the web
session route at 4222 modules (one fewer: the dropped web persistence file).

* test(mobile): re-record RPC goldens for the deferred pairing save

Repins baseline to the pairing fix commit and re-records every golden.
Against main, 781 goldens move only in the header (baseline everywhere,
adapterSha256 for the pairing adapter family). Six pairing goldens move in
the body, and only in effect order: the pairing now resolves (closing its
candidate sockets) before the naming step saves the host, and a refused
relay provision clears its journal before the save rather than after. The
set of effects and every outcome match main.

This also removes the unhandled-rejection effects and the failed
result-absent/result-null cells that the previous recording captured from
the pairing race's throwing status read.

* fix(mobile): keep the machine name out of the host header title while the label loads

The host screen starts with an empty saved label and loads it asynchronously. With a descriptor
already in memory, the resolver fell back to the machine name as the title for that window, so
opening "Windows-Low Spec" briefly titled the header with the Mac's name. Read the descriptor
only once the label is known.

* test(mobile): build the pending-pairing status through its schema so the test typechecks

The mobile tests typecheck ratchet rejected a partial status literal: the reply type keeps every
optional field as a required key. Parsing the literal through the status schema yields that shape
without a type assertion.

* test(mobile-web): re-pin the session route closure after merging main

#22301 added two src/shared modules this route reaches; its own CI never ran this suite, so the
merged branch read 4224 against the 4222 pin. Measured on the merge.

* feat(mobile): name each host by what its desktop reports

Pairing saves the host immediately again and names it after the machine name the
desktop publishes; every connection refreshes that name and OS, so a rename on the
desktop reaches the phone. A name typed on the phone's Edit screen is kept as a
phone-local override that wins; clearing it returns to the desktop's name.

- Stored host profiles gain optional personalName, lastKnownMachineName and
  lastKnownHostPlatform; `name` stays the resolved value older builds read.
  Legacy records classify a generated "Host N" as desktop-named and any other
  name as a phone override.
- The connection layer runs one retrying status probe per connected host and
  records the descriptor; the capability probe becomes a projection of it.
- Rows and the header always show the OS, add the machine name under a phone
  override that hides it, and mark it "Last known" while the host is offline.
- Removes the deferred-save naming screen and the one-shot home status fetch.
- Name rules move to host-name-identity.ts and the host-list mutation queue to
  host-list-mutation-queue.ts; an unchanged mutation no longer rewrites storage.

* test(mobile): restore the RPC recordings to main's

Pairing saves the host back to back again and the recording adapter is main's, so
every recording matches main byte for byte; the earlier deferred-save re-record and
its baseline repin no longer apply.

* fix(mobile): keep stored host name identity across snapshot saves and on the web page

A connection re-saves its host profile snapshot on relay credential rotation or relay
re-resolution. The re-pair merge let that snapshot's name identity win, so a phone rename
cleared or changed since connect came back, and a newer desktop machine name rolled back.
The stored record now keeps the name identity on every save; the save supplies the rest.

The web page receives only the app's resolved host name, with no identity fields, so the
docked host header treated a phone rename as "no override" and titled the host with the
live machine name. The display hook now reads such a source the way storage reads a legacy
record: a non-generated name is the user's label.

* fix(mobile): hand the web page the host's stored name identity

The page received only the app's resolved host name, so it had to guess whether that
name was the phone's override or the desktop's name. It guessed "override" for any
non-generated name, which froze a desktop-adopted name as the title after the desktop
was renamed, and left an offline page header without the last-known OS and machine name.

The shell now puts the stored personalName and last-known descriptor on the init host as
optional fields. The page reads them exactly as the app does; a page handed its host by an
older shell still falls back to classifying the name, and an older page ignores the fields.

* fix(mobile): drop an unreadable host name field, not the whole host

The stored host record and the page's init host checked the platform against a closed list
and required non-empty names. A value this build does not know, such as a platform added in a
later build, failed the whole record: the host list dropped the paired host and the next write
persisted the list without it, and the page refused its init message. Those three optional
fields are now salvaged, so an unreadable value drops only that field.

Also states the one exception to the stored name rule (an OS reported without a machine name
keeps the adopted name), and brings four mobile test files in line with the branch: the edit
screen now saves `personalName`, and host opens now start a descriptor status probe.

* refactor(mobile): name the shared host name fields for their role

* fix(mobile): drop the "Last known" prefix from the host machine line

The OS and machine name line under a host's name reads the same whether or not the
host is connected; the connection status already says when it is offline, and a
prefix that users could read as applying to the name added nothing. Removes the
resolver's liveness input and the desktop row's translation key.
2026-09-24 22:35:22 -07:00
Jinjing b419b3183e test: remove redundant mobile and GitLab checks (#22748) 2026-09-24 20:45:15 -07:00
Jinwoo Hong c4e58fac56 fix(mobile): restart the streamed browser pane on every return to the app (#22694)
* fix(mobile): restart the streamed browser pane on every return to the app

The browser pane stops its screencast when the app leaves the foreground and
starts a new one when it comes back, keyed on an `appActive` boolean. When the
leave and the return are handled in one React render (the JS thread was held
across the whole trip, as a suspended or frozen app is), React applies
false-then-true as no change, so the stream effect never re-runs: the old
subscription is kept and no new one starts. If the desktop ended that
subscription while the phone was away (it evicts a viewer whose socket
refuses 90 frames in a row), the pane shows the last frame it had
indefinitely, with no error and nothing that would restart it.

The pane now keeps `foregroundVisit`: null while the app is away and a new id
on each return. A batched leave-and-return still moves it to a new value, so
every return starts a fresh stream, and the host's start snapshot repaints the
pane.

* refactor(mobile): drop a busy reset both stream-effect branches overwrite
2026-09-24 14:47:12 -04:00
Jinwoo Hong 060743d813 fix(mobile): keep the streamed browser pane flipping on slow phones, and stop double taps (#22392)
* fix(mobile): keep the streamed browser pane flipping on slow phones, and stop double taps

Frame pacing. The pane decodes each frame on a hidden layer and flips to it on
onLoad. While one frame decoded, every newer frame re-pointed that same hidden
layer, which cancels the in-flight load. On a phone that decodes a frame
slower than frames arrive (~10/s during page loads, menus, spinners), onLoad
never fired for any of them and the pane sat on an old frame until the page
went still. A decoding layer is now never re-pointed: only the newest frame is
held, and it takes the layer once the decode settles. A 1.5s watchdog frees a
layer whose decode never reports, and a frame the hidden layer already holds
(a blinking caret alternating two frames) flips at once, since an unchanged
source reloads nothing.

Double taps. When browser.mouseClick failed, the pane replayed the tap as
move/down/up. On a timeout the click is still queued on the host, so the
replay landed a second tap on whatever the first one opened. The replay now
runs only when the click definitely did not reach the host.

* test(mobile): re-record the corpus without the timed-out tap replay

The corpus certified the move/down/up replay after a transport-rejected
browser.mouseClick, which the commit before removes. Scoped like #22179:
baseline bumped by editing that one line, then --record.

788 files. Every changed line classified:
- `baseline`: 787 files (786 goldens + pilot-scenarios.json), nothing else.
- matrix-browser.pointer-click-browser.mouseclick-1.json: the
  transport-rejection and transport-rejection-no-message partitions of
  browser-pointer-click-fallback now send only browser.mouseClick#1. The
  refused partitions still replay, unchanged.

* test(mobile): move the session closure pin past #22452's main-agent-status modules

#22452 changed only src/shared, so its CI never ran the page-closure suite; main
now measures 4220 modules (1034 local) against a pin of 4218. This branch adds
nothing to the closure: its own count matches main's.

* docs(mobile): say a re-pointed decoding layer loses its onLoad, as measured on Android

* refactor(mobile): give the streamed browser pane's double buffer one owner

The frame pacing, decode watchdog, layer flip and reset were spread over three
hooks and a helper module, wired back through the stream hook and the pane.
They now live in one plain pacer (browser-frame-pacer.ts) with one timer, and
the pane binds each layer's View/Image straight to it.

Behaviour fixed on the way, each with a failing test first:
- A slow last frame with nothing newer queued was abandoned by the watchdog and
  never shown. The decode deadline now only applies when a newer frame waits.
- Any pane re-render re-pointed both layers at the newest frame behind the
  pacer's back, so a blinking caret froze. The Image source prop is now only
  the mount-time frame; every later source write is the pacer's.
- A frame that failed to decode left its layer marked as holding it, so an
  identical frame flipped to an undecoded layer. Giving up on a decode now
  clears the layer's source.
- A native onLoad for a source the layer has since moved off could flip early.
  The flip now checks nativeEvent.source.uri, which Android and iOS Fabric both
  report as the raw source string; RN Web's own load event has none, so the web
  flips only through its decode probe.

The session closure pin drops by the two modules this removes.

* refactor(mobile): send the tap's mouseClick directly instead of through a flag

The delivery-unknown check was a mutable flag set inside the request callback.
The click now calls browser.mouseClick itself in a try/catch: a delivered click
returns, a delivery-unknown failure returns without replaying, and a refusal or
null result still replays as move/down/up. Same wire traffic; the corpus
certifies it unchanged.

* fix(mobile): never cut a streamed frame's decode short

The 1.5 s decode watchdog abandoned a slow decode whenever a newer frame was
queued and re-pointed its layer. On an Android emulator under load that is the
original freeze again: noise frames decode in 2-10 s, every abandon starts a
decode the next abandon cuts, Fresco reports the superseded loads (30 stale
onLoads in one run) and the pane showed 11 of 41 applied frames. Without it,
the same run flips every applied frame (16/16, no stale load), and a slow last
frame is shown in every cycle.

Nothing else needs it: with the layer never re-pointed mid-decode, native
answers every load with onLoad or onError, and the web probe's decode()
always settles. The pacer keeps one timer, for the interval.

* fix(mobile): track what each frame layer's Image holds, so no write goes unanswered

The pacer cleared a layer's source when it gave up on a decode (a reset
mid-decode, or a failed decode) while the native Image still held it. The
next identical frame was then written again, which is a native no-op on
Android (ReactImageView.setSource returns on equal sources) and iOS
(ImageShadowNode skips equal requests): no onLoad, no onError, and the pane
stayed frozen until the stream restarted. Returning from the background to
an unchanged page is enough to trigger it.

Each layer now records the source its Image holds and whether that source
has answered (loading, ready, failed). A layer is written only when it is
not loading and only with a different source, so every write gets exactly
one answer. A reset no longer abandons anything; the load under way still
answers for its layer. A frame the hidden layer already holds flips at once
if it decoded and is skipped if it failed.

The pane test's native model now treats a same-source write as a no-op and
answers each change once with the layer's current source; both new cases
(reset mid-decode then the same frame, failed decode then the same frame)
freeze on the previous head.

Also, per review: the pacer no longer touches busy or metadata. The stream
hook creates it and receives each frame as it goes on screen, so metadata
(and with it touch mapping) now follows the visible frame rather than one
still decoding.

* test(mobile): hold the pane test's AppState listener without a type assertion

* fix(mobile): never replay a tap the host answered

A fulfilled browser.mouseClick ran on the host, but a null result still
replayed it as move/down/up. The native bridge always answers { clicked },
while the external-Chromium provider returns agent-browser's `data` as is,
which can be null, so a right-click there was a double tap. Only a refusal
that is not delivery-unknown now replays.

* test(mobile): re-record the corpus for the answered-tap rule and rename its checkpoint

The commit before stops replaying a tap the host answered with a null
result. The seed's checkpoint was named clicked-by-fallback, which several
partitions no longer do, so it is renamed tap-settled in the same record.
Baseline bumped to 2c2b84ce9e by editing that line, then --record.

788 files, 841 changed lines each side. Every one classified:
- `baseline`: 787 goldens + pilot-scenarios.json.
- the checkpoint rename: pilot-scenarios.json (1), its id in
  browser-pointer-click-fallback.json (1) and the eleven partition ids in
  each of the four matrix-browser.pointer-click-*-1.json (44), plus
  `scenarioSha256` in those five goldens.
- behaviour, one checkpoint: the result-null partition in
  matrix-browser.pointer-click-browser.mouseclick-1.json now sends only
  browser.mouseClick#1 (sender and payloads drop move/down/up). The refused,
  method-not-found and result-absent partitions still replay, unchanged.

* fix(mobile): cover the frame layer remount, and trim the pacer's edges

Nothing tested the remount path: a fresh Image loads the source it mounted
with, so the pacer re-arms that load and puts back what the layer held.
The pane test's native model now mounts each host fresh (an Image loads its
mount source), and a new case remounts both Images mid-decode through a
zero-size layout: the visible layer keeps its frame and the stream goes on.
Deleting either the re-arm or the put-back fails it.

Also per review: drop the dead mountedUri guard in drain, return early from
attachImage when nothing is mounted, say that replace writes over a loading
layer, unexport the unused pacer types, cover the modifier bail in the tap's
comment, and move the frameUri state into the stream hook, which now returns
the source the layers mount with.
2026-09-24 13:37:51 -04:00
Brennan Benson 7a4f080086 revert: #18790 (orchestration incarnation reap fallback and bundled Freebuff agent) (#22601)
This reverts commit 0677271709.

#18790 was merged as one squash commit that carried two unrelated changes:
a process-incarnation fallback for reaping leaked orchestration worker
terminals, and an unannounced "Freebuff" third-party agent (catalog entry,
icon, locale strings, README rows). The Freebuff agent was never meant to
ship, so the whole PR is reverted; the reap fix should be re-submitted on
its own.

Until that re-land, a worker whose durable terminal handle goes stale is
again reported missing on release/stop instead of being re-found through
its process incarnation, so its terminal can leak on Remote Server.

The mobile session page closure pin moves 4218 -> 4219: the revert drops
the freebuff icon #22119 pinned (-1), and #22452 had already added two
src/shared modules without re-pinning (+2).
2026-09-23 21:39:28 -07:00
Jinwoo Hong 37820f9683 feat(mobile): the page owns its safe area, like a native screen (OTA phase C follow-up) (#22570)
* feat(mobile): the page owns its safe area, like a native screen

The shell reserved both system-bar strips outside the WebView and painted
them bgBase, so every page screen showed a flat band above its header,
sheet scrims stopped short of the status bar, and the dock floated above
the gesture bar.

For a page that declares `safe-area-insets` in `ready.accepts`, the shell
now draws the WebView edge-to-edge and keeps only the keyboard strip off
it. `init` carries the insets the view sits under (bottom 0 while the
keyboard ends the view, top 0 under the update banner), and a move is
re-sent over the existing route-update `init`. An older page keeps the
reserved strips, since it has no reader for the insets.

On the page, a root layout (`app/_layout.web.tsx`) feeds those insets to
react-native-safe-area-context below ExpoRoot's env()-measuring provider.
It also replaces expo-router's DefaultNavigator, an all-edges SafeAreaView
that padded a second time.

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

* test(mobile-web): re-measure the page pins for the root layout

The page's route tree gained `./_layout.tsx` (its web sibling of the
native root), so every pin that counts the tree moved:

- Script sweep re-measured by building `routes.slice(0, n)` for each n.
  It reads 69 scripts at 16 routes, which matches the real build. The
  asset-ceiling crossing moves from 31 routes to 32.
- Route closures now enter through both layouts. `entryNames` gains
  `[dir]` because `app/_layout` and `app/h/_layout` share a name.
- Session closure pin 4216 -> 4219. The added modules are
  bridge-safe-area-insets, page-safe-area-provider and _layout.web.
- The web-overrides allowlist names `app/_layout.web.tsx`.

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

* fix(mobile): read safe-area ownership from the page-document state

Review fixes on the page-owns-safe-area change.

- Ownership is page-document state now. `page-ready` carries `accepts`
  beside `reports`, the patch sets `pageOwnsSafeArea` from
  `safe-area-insets`, and the session hook projects it like
  `backClaimed`. The screen's own per-session copy is gone.
- Insets moves re-send `init` only to a page that declared
  `safe-area-insets`. A page that took route updates but not insets was
  sent a useless `init` on every keyboard show and hide.
- The banner wrapper is gone. The root pads the status bar strip while
  the banner shows.
- The shell session defaults the insets inline, with no predicate that
  mutated its argument.
- The provider is folded into its single caller, `app/_layout.web.tsx`.
  The session closure pin reads 4218 (local 1032).
- The screen tests share their module mocks, and the safe-area cases
  move to a suite of their own: owned page, banner, iOS and Android
  keyboard, and an older page that gets no re-init.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 20:08:00 -04:00
Jinwoo Hong 5b6a857e41 fix(mobile): route the bottom drawer's keyboard through the platform seam (OTA phase C follow-up) (#22556)
* fix(mobile): route the bottom drawer's keyboard through the platform seam

Fill-mode sheets called Keyboard.metrics() directly, which react-native-web
does not implement, so opening one on the page threw and the shell
re-downloaded the workspace. The drawer now reads useSoftKeyboard, whose
native half seeds from metrics() and carries the event duration, and whose
web half answers from the window (duration 0). The fill/content-sized seed
rule and resolveBottomDrawerKeyboardInset are unchanged. A census keeps
Keyboard.metrics/addListener inside the seam plus the tab-sheet hide wait.

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

* test(config): retire the drawer's exemption from the page keyboard census

The bottom drawer now reads the keyboard seam, so no module in the
source-control or review closures names react-native-web's Keyboard stub.
The census also flags Keyboard.metrics, which the stub lacks.

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

* refactor(mobile): give the drawer an imperative keyboard pair from the seam

The seam now exports subscribeSoftKeyboard and currentSoftKeyboardHeight
beside its hooks. The drawer's effect is back to its original shape with
only its Keyboard calls swapped for the pair, and useSoftKeyboard is back
to {height, visible} with no metrics() seed. Seeding every consumer opened
an iOS window between willHide and didHide where metrics() still reads
open. The web pair answers from visualViewport, so it stays silent inside
the shell and lifts sheets in a plain mobile browser.

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

* fix(mobile): start the web keyboard subscription from the current strip

A keyboard already covering the page when subscribeSoftKeyboard attached
never produced onHide when it closed, so the occlusion hook and a seeded
fill sheet stayed lifted. Outside the shell only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 19:46:52 -04:00
Brennan Benson 069dc8a1d8 feat(agent-launch): let a caller reserve the chat session, and start terminal launches with the session picks (#22523)
* feat(agent-launch): let a caller reserve the chat session and carry session picks to a terminal launch

* fix(agent-launch): keep a caller-minted session id named for its agent, and mint the fallback the same way

* test(mobile): model the older host from the launch fields, not the refined schema

* docs(agent-launch): describe the reserved session id as conversation identity, not placement

The caller mints the session id so it knows which conversation it
started; tab placement is not keyed on it. Also puts the terminal
surface's doc comment back on createTerminalSurface.

* docs(agent-launch): say a terminal launch reads the session picks on the wire contract

The `sessionOptions` field doc still said a terminal launch ignores them, which this branch changed.

* fix(agent-launch): check a reserved session id's token after the agent name, not the whole id

A hyphenated agent name failed the one-token check, so any session id for such an agent was
refused at the wire, while every other agent without a chat has its id ignored on the terminal.
2026-09-23 16:45:28 -07:00
Jinwoo Hong 996f9cc306 feat(mobile-web-bundle): gzipped 384 KiB ranges over a capability-negotiated mobileWeb.bundle.range (OTA phase C follow-up) (#22381)
* feat(mobile-web): serve gzipped 384 KiB bundle ranges behind a capability

Adds mobileWeb.bundle.range with its own strict params and result, so
shipped chunk readers see no reply change. The host gzips each range at
level 6 and sends identity when gzip does not shrink it, sharing the chunk
method's read-slot budget and per-asset verification. status.get
advertises mobileWeb.bundle.range.v1 beside mobileWeb.bundle.v1, and the
method is allowlisted for paired phones.

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

* feat(mobile): read the bundle range capability and range replies

Adds the range reply reader and operation, and picks range or chunk from
the status.get capabilities the connection already proved, so an older
desktop keeps being paged in chunks with no probe round trip.

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

* perf(mobile): keep four bundle chunk reads in flight across the whole manifest

The fetch ran one worker per asset and paged inside an asset sequentially, so
the largest script's 71 chunks were 71 serial round trips while the other
readers idled. One window of four chunk reads now covers every (asset, offset)
on the host's chunk grid, largest asset first. A read_limited refusal narrows
the window and retries the read; eof is still read from the reply.

Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19.

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

* chore(mobile): add fflate 0.8.2 for gzip bundle ranges

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

* feat(mobile): decode gzip bundle ranges into a bounded buffer

Inflates each range into a buffer one byte past its window, so a gzip
bomb costs at most that allocation and an overlong body is visible. A
corrupt, truncated or unknown-encoding body refuses as range-undecodable;
a body of the wrong decoded length refuses as range-length-mismatch.

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

* feat(mobile): pass the bundle read method from the session to the fetch

The download reads the capabilities of the gates the reducer decided
under and hands the fetch range or chunk. The fetch does not act on it
yet; the range read lands on the pipelined window.

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

* test(mobile): re-record the bundle-fetch family under pipelined reads

Baseline moves to a1ee317368, the pipelined fetch.
781 goldens change only their `baseline` header line. Six bodies move:
mobile-web-bundle-fetch-paged, mobile-web-bundle-build-changed, and the four
matrix-mobileweb.bundle-fetch-* goldens.

The two bundle-fetch scenarios now bind requests in pipelined order, largest
asset first, with every chunk sent before any reply: index.html@0 (#1),
index.html@16 (#2), assets/app.js@0 (#3).
- fetch-paged: the request set is identical, only reordered. The chunk
  sender names/ordinals and the scenarioSha256 moved; the replies and the
  fetched bytes did not.
- build-changed: the same reorder, plus one request that is new because
  pipelining puts it in flight before the refusal lands (index.html@16).
  The refusal and the checkpoint are unchanged.

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

* docs(mobile): the bundle chunk comment no longer says a reply picks the next offset

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

* feat(mobile): read gzipped bundle ranges on the pipelined window

A host that advertised mobileWeb.bundle.range.v1 is paged in 384 KiB
ranges on the range grid, through the same four-read window and queue as
chunks; any other host keeps the chunk grid. Each range is decoded to its
exact window length before the fill checks and the asset hash.

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

* test(mobile): re-pin the recording corpus after the bundle comment fix

Baseline moves from a1ee317368 to e94bde327d,
the comment-only commit on a fenced path. All 787 goldens and the scenarios file
change only their `baseline` line. No golden body moved.

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

* test(mobile): re-record the corpus at the range-read pin

Repins baseline to the range-read commit and re-records every golden.
Only the baseline and lockfileSha256 headers move: the lockfile gained
fflate, and the bundle-fetch adapter pages the chunk path, whose
requests and replies are unchanged, so no golden body moves.

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

* docs(mobile): state the on-settle reason that holds for pipelined bundle reads

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

* test(mobile): re-pin the recording corpus after the on-settle comment fix

Baseline moves from 252c592b52 to fe41226ef5,
the comment-only commit on a fenced path. Re-recorded: all 787 goldens and
the scenarios file change only their baseline line. No golden body moved.

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

* chore(mobile): keep the lockfile's patch block in main's form

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

* test(mobile): re-pin the recording corpus after the lockfile patch-block restore

Baseline moves from fe41226ef5 to 84d6fca6e7,
which restores main's patchedDependencies form in mobile/pnpm-lock.yaml.
Re-recorded: all 787 goldens change only baseline and lockfileSha256, and
the scenarios file only baseline. No golden body moved.

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

* refactor(mobile): pool four workers over planned bundle chunks, report progress per chunk

Design-review fix round, sketch C: four workers take reads from one planned
chunk queue, largest asset first. They replace the central pump and the
read_limited narrowing. A host frees its read slot before it replies, so a lone
fetch capped at four cannot trip the limit. A refusal now fails the fetch, as
it did on base, and stops the other reads.

- Each asset's buffer is allocated when the plan is built. That removes the
  nullable buffer and its guard. The per-asset byte count is gone, and the
  hash is the oracle (S1, S2).
- The caller's signal is checked before each read and once after the pool
  drains, so an abort during the final window rejects with fetch-stopped
  (S3). The stopped check now covers only the caller's abort. The internal
  stop only makes late replies skip checks, hashing and progress (S4).
- Progress is reported per accepted chunk. completedAssets still counts on
  completion (S6).
- Renames: MAX_CONCURRENT_CHUNK_READS, and `reply` for the RPC reply (N1).
- The slot check states exact-slot acceptance once, then classifies the
  refusal (N3).
- Stale test titles and comments are renamed (N4).

The synthetic manifest still takes 19 round trips.

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

* docs(mobile): say why bundle reads settle at on-settle under pipelined chunks

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

* refactor(mobile-web): announce bundle ranges on the manifest reply

The manifest reply now names the range grid in an optional rangeBytes,
beside chunkBytes, and the status capability is gone. The range method
takes exactly the chunk params on that grid instead of a caller length.
Both methods share one verified read that returns the six-field header,
and the range handler checks the connection again before deflating.
Range schemas move into the bundle RPC contract; SHA256_PATTERN is shared
from the manifest contract. Shared refusals are tested once over both
methods.

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

* refactor(mobile): drop the range capability read and its session threading

The phone will read rangeBytes off the loose manifest reply instead, so
the read-method module goes and the session effects and hook return to
the pipeline branch's version. Range imports move to the bundle RPC
contract, the reply reader reuses the shared SHA256_PATTERN, and a new
test pins that node's level-6 gzip from the host encoder inflates with
fflate to the same bytes. The fetch and window-read modules still import
the deleted names until part 2.

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

* test(mobile): re-record the bundle-fetch family under per-chunk progress

Baseline moves to 34fda6f62e. 782 goldens and
the scenarios file change only their `baseline` line. Five bodies move:
mobile-web-bundle-fetch-paged and the four matrix-mobileweb.bundle-fetch-*
goldens. The only change is bundle-progress effects. One report now lands
after the first accepted chunk of index.html (0 assets, 16 bytes), and the
later progress ordinals shift by one. Requests, replies and fetched bytes
are identical. mobile-web-bundle-build-changed keeps its body, because its
one accepted chunk is the whole of assets/app.js.

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

* feat(mobile): page bundle ranges through one window reader chosen by the manifest

The fetch keeps the pipeline's four-worker pool and builds one window
reader from the manifest reply: ranges on rangeBytes when the host names
it, chunks on chunkBytes otherwise. The reader returns the six-field
header and a lazy bytes() so the stop and misroute checks run before any
decode. A range that inflates to the wrong length now falls to the slot
checks, with the one-byte-over buffer as the memory bound, so
range-length-mismatch is gone. A rangeBytes this build cannot page reads
as absent. Fetch names say window, not chunk.

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

* test(mobile): drain the fake host after the fetch settles so the sibling-stop bounds can fail

The wave host stopped releasing replies once the fetch settled. Reads that
should have been stopped were never answered, so the read_limited bound (7)
and the chunk-failure bound (5) held even with no sibling stop at all. It now
drains until nothing waits. With the worker's stopped.abort() removed, both
bounds fail at 76 requests. assertChunkDescribesAsset's parameter is renamed
to `reply`.

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

* test(mobile): re-pin the recording corpus at the window-reader commit

Baseline moves to a25a355546. Re-recorded:
every golden and the scenarios file move only baseline, and the five
bundle-fetch goldens also move lockfileSha256 to this branch's lockfile.
Every golden body is byte-identical to the pipeline branch's. The
recording adapter's scripted host names no rangeBytes, so the bundle
family still records the chunk path.

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

* test(mobile): re-pin the recording corpus after the sibling-stop test fix

Baseline moves from 34fda6f62e to 97b13ec7f0.
All 787 goldens and the scenarios file change only their `baseline` line. No
golden body moved.

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

* test(mobile): re-pin the recording corpus at the second pipeline merge

Baseline moves from a25a355546 to 4d2cab31e5,
the merge of the pipeline's sibling-stop test fix. Re-recorded: every golden
and the scenarios file move only baseline. Against the pipeline branch, only
baseline and lockfileSha256 differ; every golden body is identical.

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

* test(mobile): read the bomb inflation without depending on call order

With the fetch's sibling stop removed, a read left over from the previous
test inflated into the bomb test's record first, and indexOf(601) picked
it. The test now asserts some inflation stopped at 601 and none exceeded
its buffer, whatever else ran.

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

* test(mobile): re-pin the recording corpus at the bomb-test fix

Baseline moves from 4d2cab31e5 to 73fde15487,
the test-only commit on a fenced path. All 787 goldens and the scenarios
file change only their baseline line. No golden body moved.

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

* refactor(mobile-web): tighten the bundle window contract and pin the range sibling stop

The range bomb test reads only its own host's inflations, keyed by the
gzip bodies that host sent, and plans twenty reads so a missing sibling
stop is visible: with stopped.abort() removed it sends all twenty.
The range params are an alias of the chunk params, and the chunk data
bound is the exact base64 length of a full chunk. The phone's chunk and
range replies share one header shape. The window reader closes over the
client and bytes() takes the slot length the fetch computes. The host's
positional read is readMobileWebBundleAssetWindow.

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

* test(mobile): re-pin the recording corpus at the window-contract commit

Baseline moves from 73fde15487 to 7346e005a3.
All 787 goldens and the scenarios file change only their baseline line.
No golden body moved.

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

* test(mobile): re-pin the recording corpus at the main merge

Baseline moves from 7346e005a3 to 9c0fe1a546,
the merge of main at 98a6a5325c. Recorded with --record: all 787 goldens
and the scenarios file change only their baseline line. No golden body
moved.

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

* refactor(mobile-web): drop a test cast and shape-named field maps

The bomb test's inflation log is typed by its hoisted factory's return
instead of an assertion, and the zod field maps shared by the bundle
window schemas are windowParamsFields and windowHeaderFields.

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

* test(mobile): re-pin the recording corpus at the lint fix

Baseline moves from 9c0fe1a546 to f4f0915e70.
Recorded with --record: all 787 goldens and the scenarios file change only
their baseline line. No golden body moved.

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

* test(mobile): page the range fetch fixture on a small advertised grid

The fake host names a 4 KiB range grid and a 1 KiB chunk grid on its
manifest reply, which the phone pages as it would the real ones, so the
fixtures shrink to a few KiB with the same shapes and each asset is
hashed once. The file runs in about 360 ms instead of 3.5 s, which a
loaded CI runner pushed past the 5 s test timeout. The desktop range
suite still pins that the real host names MOBILE_WEB_BUNDLE_RANGE_BYTES.

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

* test(mobile): re-pin the recording corpus at the range fixture fix

Baseline moves from f4f0915e70 to d4d6aadea2.
Recorded with --record: all 787 goldens and the scenarios file change only
their baseline line. No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 01:34:49 -04:00
Jinwoo Hong 0c2514e7e0 perf(mobile): keep four bundle chunk reads in flight across the whole manifest (OTA phase C follow-up) (#22376)
* perf(mobile): keep four bundle chunk reads in flight across the whole manifest

The fetch ran one worker per asset and paged inside an asset sequentially, so
the largest script's 71 chunks were 71 serial round trips while the other
readers idled. One window of four chunk reads now covers every (asset, offset)
on the host's chunk grid, largest asset first. A read_limited refusal narrows
the window and retries the read; eof is still read from the reply.

Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19.

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

* test(mobile): re-record the bundle-fetch family under pipelined reads

Baseline moves to a1ee317368, the pipelined fetch.
781 goldens change only their `baseline` header line. Six bodies move:
mobile-web-bundle-fetch-paged, mobile-web-bundle-build-changed, and the four
matrix-mobileweb.bundle-fetch-* goldens.

The two bundle-fetch scenarios now bind requests in pipelined order, largest
asset first, with every chunk sent before any reply: index.html@0 (#1),
index.html@16 (#2), assets/app.js@0 (#3).
- fetch-paged: the request set is identical, only reordered. The chunk
  sender names/ordinals and the scenarioSha256 moved; the replies and the
  fetched bytes did not.
- build-changed: the same reorder, plus one request that is new because
  pipelining puts it in flight before the refusal lands (index.html@16).
  The refusal and the checkpoint are unchanged.

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

* docs(mobile): the bundle chunk comment no longer says a reply picks the next offset

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

* test(mobile): re-pin the recording corpus after the bundle comment fix

Baseline moves from a1ee317368 to e94bde327d,
the comment-only commit on a fenced path. All 787 goldens and the scenarios file
change only their `baseline` line. No golden body moved.

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

* refactor(mobile): pool four workers over planned bundle chunks, report progress per chunk

Design-review fix round, sketch C: four workers take reads from one planned
chunk queue, largest asset first. They replace the central pump and the
read_limited narrowing. A host frees its read slot before it replies, so a lone
fetch capped at four cannot trip the limit. A refusal now fails the fetch, as
it did on base, and stops the other reads.

- Each asset's buffer is allocated when the plan is built. That removes the
  nullable buffer and its guard. The per-asset byte count is gone, and the
  hash is the oracle (S1, S2).
- The caller's signal is checked before each read and once after the pool
  drains, so an abort during the final window rejects with fetch-stopped
  (S3). The stopped check now covers only the caller's abort. The internal
  stop only makes late replies skip checks, hashing and progress (S4).
- Progress is reported per accepted chunk. completedAssets still counts on
  completion (S6).
- Renames: MAX_CONCURRENT_CHUNK_READS, and `reply` for the RPC reply (N1).
- The slot check states exact-slot acceptance once, then classifies the
  refusal (N3).
- Stale test titles and comments are renamed (N4).

The synthetic manifest still takes 19 round trips.

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

* docs(mobile): say why bundle reads settle at on-settle under pipelined chunks

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

* test(mobile): re-record the bundle-fetch family under per-chunk progress

Baseline moves to 34fda6f62e. 782 goldens and
the scenarios file change only their `baseline` line. Five bodies move:
mobile-web-bundle-fetch-paged and the four matrix-mobileweb.bundle-fetch-*
goldens. The only change is bundle-progress effects. One report now lands
after the first accepted chunk of index.html (0 assets, 16 bytes), and the
later progress ordinals shift by one. Requests, replies and fetched bytes
are identical. mobile-web-bundle-build-changed keeps its body, because its
one accepted chunk is the whole of assets/app.js.

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

* test(mobile): drain the fake host after the fetch settles so the sibling-stop bounds can fail

The wave host stopped releasing replies once the fetch settled. Reads that
should have been stopped were never answered, so the read_limited bound (7)
and the chunk-failure bound (5) held even with no sibling stop at all. It now
drains until nothing waits. With the worker's stopped.abort() removed, both
bounds fail at 76 requests. assertChunkDescribesAsset's parameter is renamed
to `reply`.

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

* test(mobile): re-pin the recording corpus after the sibling-stop test fix

Baseline moves from 34fda6f62e to 97b13ec7f0.
All 787 goldens and the scenarios file change only their `baseline` line. No
golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 00:11:12 -04:00
NeilandAdrien De oliveira ebed0964a2 feat(agents): add first-class Muse Code harness (#22216)
* feat(agents): add first-class Muse Code harness

Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage.

Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com>

Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com>

Co-authored-by: John Cusack <johncusackccm@gmail.com>

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>

* test(agents): cover Muse remote hook registration

* test(agents): cover Muse hook and source-control contracts

* test(agents): exclude Muse hook metadata from script mode check

* test(agents): keep Muse skill picker coverage stable

* test(ai-vault): include Muse in every-agent fixture

* test(mobile): repin Muse agent icon closure

* fix(muse): detect questions and approvals from structured Muse signals

Muse 1.3 fires no hook for request_user_input, so a pending question left
the pane "working". Its internal reminder subagents also post hooks with
their own session ids (even after Stop), which surfaced "tool failed" rows
and flipped finished panes back to working.

- Read pending questions from Muse's session log
  (user_input_prompt_requested/settled) via the existing transcript poll,
  now generalized from Codex subagents to Muse on main and relay.
- Drop child-session hooks (SubagentStart ids, or turn_id === session_id).
- Treat Notification permission_prompt as the approval wait; PermissionRequest
  also fires for auto-approved calls, so it only caches the approval card.
- Ignore Notification copy as the prompt; poll replays are not new prompts
  or turn boundaries.
- Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook.

* perf(muse): parse only question events from the session log

Most Muse session-log lines are large model/tool records. Filter raw lines
by the user_input_prompt_ marker before JSON.parse via an optional
readJsonlCursor line filter.

* fix(muse): unwrap batched log records and scope questions to the live turn

Review follow-ups: question events inside retained_frame batches were
skipped, and a question left open by a crash or interrupt stayed pending
for the pane's life. Share the history scanner's retained_frame unwrapper,
and only report a pending question whose run_id matches the hook turn_id.

* refactor(muse): drop type assertion in retained_frame unwrap

* fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy

---------

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>
2026-09-22 19:13:11 -07:00
Jinwoo Hong 11083ac4d3 fix(mobile): the page's auth-failed banner offers Re-pair again (#22363)
#22283 dropped all three banner actions on the page, on the claim that
/pair-scan sits outside the page's route root so Re-pair cannot work there.
It does work: the host screen's router is the route handoff, which posts a
target the page does not serve to the shell (route-handoff.web.ts:207), and
on the emulator the tap opened the native scan screen and Back returned to
the same page document.

The page's sibling now renders Re-pair as native does, wired to the same
onRepair, plus a muted line for the two it still cannot honour: "Reconnect
or remove this host from the Orca app." forceReconnect stays null on the
page and removal keeps refusing; native renders its three actions as
before. The doc comments and the web-overrides reason are corrected, and
the reason's drifted citations re-resolved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 20:24:13 -04:00
Jinwoo Hong d11b3e226d fix(mobile): page Back with a dirty markdown draft opens the unsaved-drafts prompt (#22362)
The session's Markdown actions registered BackHandler natively only, so on
the page an unsaved draft left the key unclaimed and the shell's pop dropped
the edits without the prompt. The hook now claims through useBackClaim on
both platforms: always natively, where leaveSession replaces to the host at
the root instead of exiting the app, and on the page only while a draft is
dirty, since an unclaimed press there is already the shell's own leave.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 20:09:56 -04:00
Jinwoo Hong 3f9582bc90 fix(mobile): removing a host deletes its page cache through the one process store (#22352)
* fix(mobile): removing a host deletes its page cache through the one process store

Host removal never deleted the removed host's page generation cache, so it sat on disk
until four other hosts were activated and a re-pair could reopen the old tree. Every
caller also minted its own GenerationStore with its own queue, so a removal's index or
update-failure-log write could drop the mounted session's write landing inside it.

The store is now one per process (processGenerationStore, with a reset-for-tests seam);
the shell runtime, host removal and Troubleshoot all share it, and removal deletes the
host's cache after the metadata commits, fire-and-forget beside the failure forget.

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

* test(mobile): the cold-start store per mount is the test's, not production's

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 19:57:19 -04:00
Jinwoo Hong 0e6862cbcc fix(mobile): the page offers no control whose only effect is a re-dial it cannot make (#22326)
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials

Six failed-load screens share one Retry shape: re-dial a host that is not
connected, otherwise re-read. On the page the re-dial is inert
(`client-context.web.tsx:55`) and each screen's load already re-runs when the
shell's client reconnects, so in the disconnected state that Retry did
nothing at all. `connectionRetryAction` makes the decision once and answers
null when a re-dial is needed and none exists; agent history, the file
explorer root, the file preview, git history, the source-control status gate
and the diff review render no Retry for null.

The explorer's per-folder Retry keeps its control: it queues the folder, and
the queue drains on the next `connected` whoever brought it back.

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

* fix(mobile): the page offers no re-dial, so no header offers one

`forceReconnect` on the page was `() => Promise.resolve()`: the shell owns
the connection and nothing in the document can re-dial it. The host header's
Reconnect and the session header's "tap to retry" were wired to it and did
nothing there. The context member is now nullable and the page's provider
hands out null, so the compiler found every caller: both headers render no
reconnect affordance for null, and the session status keeps the verdict
label without promising a tap.

Native providers and the recording adapters still pass a function, so
nothing a phone renders changes. The session route's host-JSX parity hash
moves for the header's extra null check; the page test doubles that stubbed
the old inert re-dial now stub null.

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

* docs(mobile): the auth-failed banner cites the page's re-dial as null

Three comments and the banner's override reason still said the page's
`forceReconnect` was an inert `() => Promise.resolve()`, and cited
`client-context.web.tsx` lines the previous commit moved. They now say null
and point at the lines that hold it.

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

* test(mobile): the session closure gains the page's Retry decision

`connection-retry-action.ts` is the one module the Retry fix adds to the
session route's page closure, reached through the explorer, source control
and git history it docks. Measured on this head with all five generators run
first, and diffed against the pre-change closure: one local module added,
none removed.

Session route closure 4207 -> 4208 modules, local 1021 -> 1022.

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

* docs(mobile): the capability probe belongs on the page, and says why

The push fence excluded `runtime-capability-probe.ts` because the session
route and the host screen run it. The session half holds, and the probe
works there: `status.get` carries no client identity and makes no write, the
shell forwards it like any non-`native.` request, and the desktop's mobile
allowlist admits it. The host-screen half no longer does:
`codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which
the bundle carries and the page hands to the native screen.

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

* test(mobile): keep the session retry test's cast under its disable line

The formatter wrapped the cast onto the line after the disable comment,
which left it uncovered. The cast now sits on its own line directly below
the SAFETY note.

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

* test(mobile): the agent-history Retry test mocks the pathname the handoff reads

Main's page route handoff now subscribes to `usePathname` (#22300), and the
Retry suite this branch added mounts that handoff with an `expo-router` mock
that lacked it. Same one-line addition main made to the back-handoff suite.

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

* test(mobile): pin the reload each hidden page Retry relies on

Hiding a Retry on the page rests on the screen's load re-running when the
shell's client reconnects, because nothing on the page re-dials. Only the
explorer's folder drain pinned that. Each other site now has a case that
starts unreachable with no Retry and asserts the load goes out on the
client and state the reconnect delivers: agent history (status.get), file
preview (the preview read), diff review (the snapshot load), git history
(git.history) and source-control status (git.status, in the loaders suite
because the panel test mocks the state hook).

Each goes red when the `client`/`connState` dependencies it guards are
removed; for source control that is both `loadStatus` and the
`loadBranchCompare` it depends on.

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

* test(mobile): one import of the transport types in the source-control loaders test

CI's native code-quality audit denies the duplicate-import warning the reload pin added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 19:41:38 -04:00
Jinwoo Hong 7240368726 feat(mobile): a failed hybrid-shell update is recorded on the device and shown in Troubleshoot (#22321)
* feat(mobile): name why a bundle fetch refused what arrived

The fetch threw plain errors whose only content was prose naming asset
paths and hashes, so a caller could not keep the cause without keeping
the prose. Each refusal now carries a code beside the unchanged message.

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

* feat(mobile): record why a hybrid shell update failed, on the device

A release build forwards no console output to logcat, so a refused or
failed page update left the fallback banner and nothing else. Every exit
from a failed update read now emits a record-update-failure effect: the
cause as a closed code (never an error message), the generation offered
and the one on disk, and what went on screen instead. The runner stamps
host id and time and the generation store appends it to a bounded log in
the cache root, five per host and twenty in all, oldest evicted first.

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

* feat(mobile): forget a removed host's recorded update failures

Removal clears the host's entries from the shell's update-failure log
after the metadata commit, unawaited and best-effort: it is evidence
about a host that is gone and never a reason to hold the removal.

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

* feat(mobile): show recorded update failures in Troubleshoot

A "Workspace updates" section lists the newest recorded failure of each
paired host: the reason, the generation offered, and what the shell
showed instead. It renders nothing until a failure has been recorded and
mounts only where the hybrid shell can run.

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

* test(mobile): type the update-failure row doubles without casts

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

* fix(mobile): forget a host's update failures once a newer generation commits

The Troubleshoot row reads "Last update from Host N failed", which stops
being true the moment a later update from that host lands. The activated
step for the build this flow downloaded now emits forget-update-failures
for the host. A cache open, an offline open and a same-build hit activate
a build the flow never requested, so they leave the record alone.

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

* test(mobile): re-pin the session closure for the shared journal producer #22299 added

main at 9ece273056 measures 4212 modules against a pin of 4211: the native-chat fix
added src/shared/agent-session-journal-producer.ts, which three shared modules on the
session route import, and its PR touched nothing under mobile/ so the mobile job never
ran. Re-measured on this merged head and the joiner read off the closure list.

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

* fix(mobile): forget update failures on a download's activation, not a build-id match

The fetch reads the manifest again and commits the build that read named,
so a host that moved between the session's read and the fetch's committed
a build other than requestedBuildId, the forget was skipped, and the stale
"last update failed" line outlived the update that should clear it. The
activating state now says where it came from, download or cache, and the
forget follows a download's activation.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 18:35:51 -04:00
Jinwoo Hong 11db2b9a7d feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key

The shell's page had no way to hear Android Back: every sheet inside it
early-returned on web, so the key popped the whole session route. Adds the
first negotiated shell-to-page frame kind alongside it.

- `back-claim`, page to shell, declared in `init.accepts`: the document is
  holding the key, or has let it go.
- `back`, shell to page, declared in `ready.accepts`: one press, dispatched to
  the newest consumer that takes it. A press nothing takes is handed back as a
  `navigate-back` rather than dropped.

Both are optional fields on frames the other side already reads, so an old
shell never hears a claim and an old page is never sent a press; each pops as
it does today. No protocol bump and no stream opcode.

`bridge-host.ts` was at its line cap, so the notify forwarder moves to
`bridge-host-notify.ts` unchanged.

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

* fix(mobile): Android Back closes the sheet on the page, not the screen

Inside the shell's page every sheet early-returned on web, so one press left
the session route with the sheet still open. The drawer, the right drawer and
the file-preview prompt now claim the key through one seam on both platforms:
`use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on
the shell's. All sixteen session sheets render through `MountedBottomDrawer`,
so the one claim there covers every one of them, and a census fails if a sheet
bypasses it.

`route-handoff.web.ts` claims while the page grew a stack of its own, and
hands the press back when it did not.

The shell takes the key off the navigator only while a claim is live: Android
gets a `hardwareBackPress` handler that returns the host's own answer, iOS
loses the stack's swipe-back. The claim is cleared on `document-started`, on a
remount, on a new `ready`, on anything that takes the generation off screen,
on the page's `close` and on dispose.

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

* test(config): a Back press closes a sheet on the real bundle

The unit suites reach both halves of the lane but never the two together on a
document a browser rendered. The render rig can now post a `back` frame, and
the drawer check opens the Filter sheet, reads the claim off the notify list,
sends one press and pins that the sheet closed with no `navigate-back` behind
it. Red without the drawer's claim: the claim never arrives.

Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is
shared with the native app and mounts under no page provider in a bare tree,
where `usePageBridgeClient` threw; the seam now reads the bridge through
`usePageBridgeClientIfPresent` and claims nothing without one.

Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route
handoff, `bridge-page-back.ts` through the envelope.

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

* refactor(mobile): mirror the Back seam's latest values from an effect

Three `ref.current = …` writes sat in render, which React replays and
discards. Each moves into a dependency-list-free effect declared ahead of the
registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already
uses for the same reason: the caller rebuilds the value every render, so there
is nothing to depend on, and `useRef` seeds the first mount. The registration
still keys on the claim alone, so a rebuilt handler re-registers nothing.

The web seam's test drops its two type assertions for a named fixture type.

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

* fix(mobile): the page says its Back claim again on every init

The claim was edge-triggered and the shell forgets on purpose: it drops the
claim answering every `ready`, and a host rebuilt under a live page — a client
swap through forceReconnect, which leaves the WebView mounted — starts with
none at all. A document still holding a sheet was then unknown to the shell,
and the next press popped the screen out from under it.

`init` is the shell saying it is here now, so the page answers each one with
the state rather than with a transition. Posted after the session has taken
the frame, so the gate reads that `init`'s own `accepts` and a shell that
never named the claim still hears nothing.

Nothing is said while nothing is held. Every `init` answering a `ready` comes
from a host that dropped the claim first, so it already holds false; the only
other one carries a rewritten route, where a stale true needs a `false` the
page posted to have never left, and a port that refused that frame refuses
this one too.

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

* fix(mobile): a rebuilt host keeps the session's Back claim

A host is rebuilt when the client under it changes, and the page document does
not move: the WebView stays mounted, the session id holds, and the page is
never told. The rebuilt host started with no claim and no `accepts`, so it
refused every press and the navigator popped the screen out from under an open
sheet. Two clients on the same generation leave the page nothing to refuse, so
nothing made it re-ask and re-assert.

What the page declared and what it is holding are facts about the session, the
way `sessionEstablished` already is. `createBridgeHostBack` takes them as a
seed, `readSessionBack()` hands them on, and the hook holds them stamped with
the session so a record left by one never seeds the next.

`dispose()` no longer reports the claim gone: a host retiring is not a
document ending, and that report was the thing taking the key off a live
sheet. Every reset path is unchanged and still has its own case — the page's
`ready`, its `close`, and the session's own store for `document-started`,
`remounted` and anything that takes the generation off screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 16:21:26 -04:00
Jinwoo Hong 564f135248 fix(mobile): an accessory Enter ends the field's editing session, and the page's Enter survives a composition (#22300)
* test(config): the page's live input never submits under an open composition

The emulator's page-only defect, in a browser: an Android soft keyboard keeps a
composition open over the word being typed, so the Enter keydown carries
`isComposing: true`, which is the condition react-native-web reads to skip
`onSubmitEditing` entirely. Nothing reaches the terminal and the field keeps the
text. The probe route now mounts `useTerminalLiveInputCommit` and the command
dock's own field props, so keys enter through the browser rather than through a
handle that calls the hook directly.

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

* style(config): format the live-input render check

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

* fix(mobile): the page's live input submits under an open composition

react-native-web's keydown handler withholds `onSubmitEditing` whenever the
Enter keydown reports a composition — `nativeEvent.isComposing`, or the Android
`keyCode` 229 that stands for it — which is a soft keyboard's normal state
mid-word. Nothing reached the terminal and the field kept the text. Native
Android's editor action has no such suppression, which is why only the page
showed it.

The field now also claims `beforeinput`/`insertLineBreak`, the browser's own
end-of-line signal. react-native-web cancels every keydown it does submit on,
so that event exists only in the cases it dropped, never twice; an IME still
choosing a candidate reports `insertCompositionText` and is left alone.

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

* test(mobile): list the live-input submit binding, and repin the session closure

The `.web.ts` sibling needed its row in `web-overrides.json`, whose check lists
exactly the overrides on disk. The session route's page closure moves with it:
the callback ref and the binding it resolves to are both local, and the native
sibling stays out, which is what the pair is for.

  modules        4207 -> 4209   (+2)
  local modules  1021 -> 1023   (+2)

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

* test(mobile): the key bar's Enter chip leaves the sent text in the live field

The device trace's variant (a), which is what shots/23 was: the chip emits no
DOM key event, so react-native-web's submit handling never runs and the
accessory hook is the only thing that could end the field's editing session.
With nothing held it takes the send-now branch, which flushes nothing and
writes neither the capture state nor the field, so the text the PTY already
echoed stays put and the next keystrokes append to it.

Not a page defect: the held-text fallback only holds a trailing non-ASCII run,
so ASCII leaves nothing held on native either. The unit case is on the shared
hook for that reason.

The probe route now models what the send actions do with 'allow-raw', so the
check can see a control sent twice or not at all.

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

* test(config): the buffered command field has the live field's Enter gate too

Three cases, one red. A plain Enter sends the draft and empties the field, which
is `beginBufferedTerminalDraftSend`'s doing and stays a guard. The key bar's
Enter chip in buffered mode is a plain terminal key: the accessory hook declines
at its live-handle guard, one carriage return goes out and the draft is
untouched, also a guard.

The red one is Enter under an open composition. This field reaches its send
through `onSubmitEditing` alone, so react-native-web's keydown gate swallows it
exactly as it did for the live field, and the draft neither goes out nor leaves
the field.

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

* fix(mobile): an accessory Enter ends the field's editing session, on both fields

Two defects, one rule: after a control that ends the line, the terminal owns
the echoed text and the field's editing session is over.

The key bar's Enter chip (device trace, variant (a)) emits no DOM key event, so
only the accessory hook could end that session. Its held-text branch does,
through the flush; with nothing held it took `send-now`, which flushed nothing
and wrote neither the capture state nor the field. Not page-only: the held-text
fallback holds a trailing non-ASCII run, so ASCII leaves nothing held on native
either. `send-now` now takes the same flush when the bytes end the line, and
still defers the send to its caller so exactly one return goes out.

The buffered command field had the live field's composition gate, because it
also reaches its send through `onSubmitEditing` alone. It binds the page's
line-break signal now too, which is why the seam is named for a terminal text
field rather than for the live input.

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

* test(mobile): the submit binding holds the handler its first render was given

Found by pullfrog on #22300. The binding refreshed its handler ref only when
the callback identity changed, so a caller memoizing on an empty dependency
list was bound once and never again. The buffered command field does exactly
that: its submit closes over handleSend, a per-render function whose guard
reads client and activeHandle, both null until effects supply them, so the
page's line-break submit could never pass that guard.

The probe route now carries the same two paths the dock has — a fresh
per-render function on the field's onSubmitEditing prop, and the memoized
closure on the binding — because the working prop path is what hid this.

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

* fix(mobile): the buffered field's page submit reaches a live handleSend

Found by pullfrog on #22300. `submitBufferedDraft` was memoized on an empty
dependency list, which froze the per-render `handleSend` it calls. That guard
reads `client`, `activeHandle` and `canSend`, none of which the first render
has, so the page's line-break submit could never pass it. The field's own
`onSubmitEditing` prop kept working, which is what hid it.

The handler is per-render now, and the binding refreshes its handler ref on
every commit rather than when the callback identity changes — the ref exists so
the listener always reaches the newest handler, and it should not rest on a
caller's memoization. That second half fixes nothing on its own: a `useCallback`
with `[]` returns one function object for the life of the component, so no ref
can find a newer closure behind it. The source census is what catches that, and
it is red against the frozen handler.

The probe route is back on the dock's shape, per-render on both submit paths,
with a note saying why a route that writes its own submit cannot catch this.

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

* test(mobile): the page's buffered submit reaches a handleSend that can send

The census beside this reads one spelling of a frozen handler. This reads the
behaviour: the send-actions hook is rendered first as a session is before its
effects land, with no client and no handle, then again as it is after, and the
listener the page's binding attached has to reach the second one. Asserted on
the params that reach the client, not on a call count.

Red with the `useCallback` restored, and red with a `useMemo` in its place,
which is the point of testing the behaviour rather than the spelling.

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

* test(mobile): declare the two mock arrays instead of asserting them

`[] as Array<T>` inside the hoisted factory was a type assertion with nothing to
explain: the arrays are built here, so a checked declaration says the same thing
and the quality gate has nothing to flag.

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

* refactor(mobile): the submit binding stops claiming a cure it does not have

The handler ref refreshed on every commit rather than on the callback identity,
and the docblock called that the reason the seam exists. It is not: a caller
that freezes its closure hands this hook one function object for the life of the
component, so no ref finds a newer one, and a caller that does not freeze it
changes identity every render and refreshes the dependency anyway. Measured both
ways. The dependency is back, and the prose says only what the code does.

The rule that does hold — a bound handler must not be frozen on an empty
dependency list — is stated where it is enforced, in the wiring census, with the
behavioural check named beside it.

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

* refactor(mobile): the submit binding's latest-ref drops its dependency list

React Doctor flags the dependency twice, once per bound field: both of the
dock's field submits are rebuilt every render, because the handleSend they read
is, so `[onSubmit]` is a new value every time and there is nothing to compare.
The tree's other latest-refs are written without a list for the same reason —
use-mobile-web-shell-bridge.ts:148 is the one this follows.

The comment says what the ref does, which is mirror the newest closure after
each commit so callers may pass per-render handlers. It claims nothing about a
caller that freezes one; that rule is still the wiring census's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 15:55:36 -04:00
Jinwoo Hong a8786c040d fix(mobile): the page never removes a host, and stops bundling push (#22283)
* fix(mobile): the page never removes a host

The page holds one host profile from `init.host` and no credential, so
`removeHost` on web resolved without doing anything and the screen reported
success for a host that was still paired. Its `.web` sibling refuses with a
typed error instead, and the auth-failed banner's Remove — the one surface
that opens the confirm — is absent on the page, because a control that can
only refuse should not be there.

Refusing is also the fence that keeps `push-registration.ts` out of the page
bundle: the native lifecycle file's import was that subsystem's only path
into a page route.

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

* test(mobile): drop the push families the page closure no longer reaches

`host-removal-lifecycle.web.ts` was `src/notifications`'s only path into a
page route, so the whole directory left the page bundle and the capability
probe left the C1 layout closure with it. The derived family set shrank by
two; the pin tables and their counts now match what the closure reaches.

The expo-notifications fence grows a second claim and loses a precondition
that had become false: `push-token.web.ts` and
`desktop-notification-channel.web.ts` are no longer in the bundle either, so
the fence is stated as the absence of the directory.

C1 20 families / 94 goldens, C2 68 / 257, C3 26 / 116, C5 25 / 125.
Session route closure 4211 -> 4207 modules.

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

* fix(mobile): the page's auth-failed banner offers no control it can honour

The banner is reachable on the page — the shell forwards the native client's
state verbatim (`bridge-host.ts:381`) and `auth-failed` is in the wire enum
(`bridge/bridge-envelope.ts:43`) — and the page can honour none of its three
actions. `forceReconnect` is `() => Promise.resolve()` there
(`client-context.web.tsx:55`, read through `host-client-hooks.ts:87`),
`/pair-scan` sits outside the page's route root of `app/h`
(`mobile-web-app-route-manifest.mjs:6`), and removal refuses. The previous
commit hid only Remove and claimed the other two still worked; they do not.

The whole action row moves into `AuthFailedBannerActions`, whose `.web`
sibling renders no control and one line naming the app. The sentence above it
is unchanged: re-pairing from the desktop is still what to do.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 12:49:25 -04:00
Jinwoo Hong 3bb9a4e261 fix(mobile): keep a painted frame under the page until its first paint (#22264)
* fix(mobile): keep a painted frame under the page until its first paint

The shell tore its own frame down the moment a generation was on screen
(`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws
nothing until its document paints. What showed for the whole of the page's boot
was the surface behind it with nothing on it: 1.42 s on a cached generation,
against a one-frame budget.

The page is the only thing that knows when it has a frame, so it says so. It
declares `painted` in `ready.reports` and posts the notify after the browser has
painted its first commit; the shell holds the same neutral frame it was already
painting while it opened the generation, then fades it out. The wait is bounded
by the declaration and never by a timer: a generation served by an older desktop
declares nothing and is uncovered on `ready`, which is what every shell did
before this.

iOS painted white rather than nothing: a WKWebView is opaque by default, so the
shell's own surface never showed through. It is now transparent, as the Android
view already was.

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

* fix(mobile): hold the cover through the compositor handover

The page reports the paint its own renderer made; putting that on the app's
surface costs another frame or two. A linear fade from the report left two
frames of bare surface between the two on an emulator, which is the hole the
cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity
across that handover: five reopens now show 0-21 ms of bare surface against
102-2043 ms on the build without it.

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

* fix(mobile): negotiate the paint report in both directions

The page posted `painted` whatever shell it met, and `notify` is a closed union:
every shell installed before this answered it with an error frame, once per
mount. The shell now advertises the name in `init.accepts` beside the param
clear and the client identity, and the page posts only when it was advertised.
The declaration in `ready.reports` stays unconditional, because it is an
optional field an older reader strips rather than a new opcode, and because the
first `ready` — the only one that matters for the first paint — is sent before
any `init` has arrived.

The accepts list moves into `bridge-init-frame.ts` beside the grants, which is
the module that builds the frame carrying it.

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

* test(mobile): read the cover's colour instead of asserting its shape

Two gate findings on the round-two head. The cover test reached the background
through a cast of the style prop; it now reads it through a checked narrowing,
so the test proves the shape it depends on rather than declaring it.

`use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args
gained `onPagePainted`: its harness is a literal, so a new required handler is a
missing property. The probe now counts paints and one case spends the counter,
which is what a handler wired only to satisfy a type would not do.

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

* refactor(mobile): move the cached-generation opening out of the reducer

`mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused-
update work and the paint handling both grew it. What comes out is one thing —
putting a generation already on disk on screen, and deciding whether this route
is one that bundle carries. It is the reducer's cache path and its refused-
update path both, and it was already three functions sitting together.

`step` goes into a module of its own because the two now share it; a copy in
each would be two spellings of one transition, and exporting it from either
would point the dependency the wrong way.

No behaviour moves: the reducer's table tests are unchanged and the page closure
is unchanged at 4,211, since neither new module is reachable from a page route.

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

* fix(mobile): drop the previous document's paint when a new one starts

A document that replaced a painted one inside the same mount inherited its
`pagePainted`, so the cover lifted before the replacement had drawn anything.
The native view already reports `loading`; the screen dropped it. It now
reaches the reducer as `document-started` and clears the page document state.

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

* test(mobile): make the declaration case call the frame policy

The case compared the name to itself and never called `shellPageFrame`, so it
passed for a policy that ignored the declaration entirely.

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

* fix(mobile): report the page's frame from the route screen, not the router

Every route screen is behind `import()`, so the wrapper above expo-router
commits with a suspense fallback while the chunk is still arriving. The paint
report hung there, which uncovered the shell's view over an empty body on a
cold chunk. It now hangs on the screen the manifest resolves, layouts excluded.

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

* fix(mobile): retire the readiness wait a replaced document armed

`document-started` cleared the page document state and left the flow alone, so
the previous document's readiness deadline passed the flow check, read
`pageReady` as false and failed a session whose replacement was still loading.
The flow moves with the document, for the reason `remounted` already moves it.

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

* fix(mobile): let a departing route screen take its paint report back

The report waits two frames, and nothing cancelled the second one, so a screen
unmounted in between still told the shell to uncover. The reporter now answers
with a take-back the wrapper returns as its cleanup, and the once-per-document
latch frees only when a report was cancelled before it landed.

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

* fix(mobile): let the screen that arrived take over a frame still owed

A screen committing inside the two frames an earlier one was owed found the
latch taken and reported nothing; the earlier screen then freed that latch on
its way out and nobody was left to lift the cover. The newest commit now
supersedes the pending report, and only a posted one spends the latch.

Covers the redirect window with a render check against the pr route, whose
target chunk is held open while the document sits on the hub's fallback.

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

* test(mobile): make the take-over case turn on the take-over

The case cancelled the first screen's frame through the cleanup path, so it
passed with the take-over deleted. It now leaves that screen mounted and reads
the clock: the frame after the replacement commits is the replacement's first,
not the one the screen behind it was still owed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 11:43:26 -04:00
Jinwoo Hong 66ade30576 fix(mobile): the page's live input stays above the soft keyboard (#22252)
* fix(mobile): the page's live input stays above the soft keyboard

Edge-to-edge makes the manifest's `adjustResize` inert, so the window never
shrinks for the IME and the page's `visualViewport` reads full height with the
keyboard up: the session route laid its live input row out under the keys. The
shell owns the window, so it shortens the WebView by the keyboard instead.

The session screen's own `Keyboard.addListener` pair never fired on
react-native-web, so the page also never held off the terminal refit. Both facts
now come from the platform seam, which answers them separately on the page: the
keyboard is open, and it covers nothing.

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

* test(mobile): type the session keyboard harness without a cast

The hoisted keyboard now carries the seam's own `SoftKeyboardState`, and the
mocks the screen is handed carry the types it calls them with, so nothing is
asserted into shape.

The scope the harness builds could not be spelled at all before: the hook took
the whole lifecycle model to read 28 of its fields. It now names those fields,
which the model still satisfies, and the test builds one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 09:06:45 -04:00
Jinwoo Hong 3d76c22b57 fix(mobile): a refused update serves the cached generation while the host is up (#22237)
* fix(mobile): a refused update serves the cached generation while the host is up

A newer generation that fails to fetch or stage was refused with a named
reason and then painted a wall with "Try again" over an intact generation
already on disk — the same one the offline branch opens without being asked
the moment the host goes away.

`onDownloadFailed` now branches on what is cached rather than on which side
refused: with nothing on disk the refusal is still the screen, and with a
generation on disk it is opened through the offline branch, judged by its own
route list. The refused generation is never staged, committed or persisted, and
nothing about the refusal is written, so the next launch asks the host again.

The bundle-side refusal is named as a dismissible notice above the page, on the
existing host-route banner. It says what happened and promises no retry,
because the shell schedules none.

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

* fix(mobile): judge the cached generation against the host it can reach before serving it

The fallback served a cached generation on the strength of the offline rule,
which skips the compat check because a host nobody can reach cannot have
changed. On this path the host has just answered, and an update usually exists
precisely because it moved — so bytes that were inside the protocol window when
they were written may be outside it now.

`CachedGeneration` now carries the three fields the compat verdict reads,
projected in `openCache` off the manifest stored beside the assets. That
manifest is never absent: `readActiveGeneration` answers null for a generation
whose manifest did not parse, and the read schema requires all three.

`cachedGenerationWall` lives beside `gateVerdict`, because only an `open` gate
is judged further. The other verdicts already have answers there: an absent
capability is the native-route rule, and an unreadable status leaves the same
empty list, so walling on either would be the `bundle-unavailable` wall that
file exists to keep off a host that simply did not reply.

A generation outside the window now earns the wall with its verdict, not the
download-failed screen, and nothing is deleted: the bytes are intact and a
newer host is not what makes them wrong.

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

* fix(mobile): ask the cached generation's own routes before walling it

The compat wall ran before the route question, so a cached generation that does
not carry this route — or predates route listing entirely, `routes: undefined` —
earned a terminal `bundle-incompatible` wall where the answer is `native-route`.
`onManifestRead` has always taken the other order: a route that stays native has
nothing to wall about. `openByOwnRoutes` now asks the route first and applies
the wall only on the served branch, and the update notice moved to `served`, so
a native answer carries no notice about a screen it is not showing.

The other half is the verdict the gates hold at the moment of the refusal. A
`fetching` session does not await the gates, so a refusal can land under a
verdict the flow never started on. `gateState` is now the one mapping from a
gate verdict to a screen, shared by the entry into the flow and by the fallback,
so the two cannot answer the same verdict differently: a host that stopped
serving a bundle is `native-route`, a status that went unreadable says so and
re-arms, a dial in progress or a pending status waits in `checking`, an
unreachable host keeps the offline rule and serves the cache unjudged, and
`open` is the only answer that leaves a host to judge the generation against.

That inverts two round-2 assertions that expected the cached page to be served
when the capability list had gone empty. Both were wrong for the same reason:
an empty list is the gate's question, not a verdict about a bundle.

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

* fix(mobile): announce the host route notice banner, as loudly as its tone

The banner is inserted into a screen that is already on screen, so a reader who
has moved past the top of the list never arrives at it. It carried no live
region and no role, so nothing carried it to them.

The urgency follows `tone` rather than being assertive for everything. The
failure tone is an action that did not happen — a refused worktree action, or
the shell's refused update — and interrupts with `alert` and an assertive
region. The notice tone is a bounced route, context for a list already being
read, and waits its turn politely; interrupting for that would train people to
ignore the first. No role on that arm: React Native has no `status` role, so the
polite region is the whole of the answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 08:39:21 -04:00
Jinwoo Hong 0748915ce2 fix(mobile): a refused dictation start says why (#22256)
The composer had two dictation failure handlers, one policy written twice.
`onError` routed a setup-required refusal to the dictation setup sheet;
`startDictation`'s own catch — the only path a refused `speech.dictation.start`
takes — did not, so a desktop whose voice settings are off or whose model dir is
empty answered the tap with its internal code as a toast.

Both entry points now call one `reportDictationFailure`, so the sheet opens for
`voice_dictation_disabled`, `voice_model_not_selected` and `voice_model_not_ready:*`
whichever path saw them, and every other refusal keeps the toast and haptic it had.

The desktop's error already carries its reason in the message, so no wire change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 08:38:09 -04:00
Jinwoo Hong 0ab2ba3480 fix(mobile): the page stops writing, importing and requesting what it cannot use (#22241)
* fix(mobile): the page keeps no host app-version record

`host-status-gates.ts` runs above every page route, and a readable
`status.get` had it write `orca:host-app-version:v1:<hostId>` through
`host-app-version-store.ts`. Inside the page AsyncStorage is the bridge's
adapter and that key is not one `page-storage-keys.ts` hands a route, so
every mount posted a write the shell refused and logged as
`storage-write-dropped`.

Not admitted through the storage seam, because the page never reads it
back: the record's only reader is the native troubleshoot screen's
`native-diagnostics-operations.ts`, which is not in the page's bundle. A
`.web` sibling keeps no record instead. The bounds check moves to
`host-app-version.ts` so both hosts read a reported version the same way.

The session render check now collects warnings as well as errors and
answers `status.get`, which is what arms the write: the other cases'
double answers no RPC, so the drop needed a reply rather than a control.

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

* fix(mobile): the page does not import expo-notifications

`DevicePushTokenAutoRegistration.fx` runs at import: it adds a push-token
listener React Native Web answers with a warning, and it reads the
persisted server registration out of `window.localStorage` behind a
`typeof localStorage === 'undefined'` guard. The Android shell's WebView
has DOM storage off, where `window.localStorage` is `null` rather than
undefined, so the guard passed and the read raised "Cannot read
properties of null (reading 'getItem')" at error level on every page
load.

Two modules imported the package — `push-token.ts` and
`desktop-notification-channel.ts`, both reached through
`push-registration.ts`, which the host layout pulls in via the host
screen's remove action. Both get a `.web` sibling. The page holds no
device push token and creates no Android channel; push registration
needs a token the shell owns and a gateway the page has no client for.

Every call in those two files was already inert on web, so a page that
imports one behaves correctly and still loads the package: the closure
check beside them is what keeps a third importer out. The session render
check adds the device's own shape — `localStorage` reading `null` — and
reds on the error the emulator saw.

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

* fix(mobile): the page declares an icon, so no browser asks for one

With none declared a browser asks the origin for /favicon.ico on its
own, and the shell's asset server answers 403 because the path is in no
manifest — which the emulator run saw repeatedly. The document now
carries `<link rel="icon" href="data:," />`, a browser's own way of
being told there is no icon. An empty data URI rather than an asset: the
page is a WebView document with no tab to put an icon in, and the
bundle's images are content-hashed route assets whose names change with
their bytes. `img-src 'self' data: https:` already admits the scheme.

Two assertions, because each is blind where the other sees. The build
check reads the document and runs everywhere. The session render check
reads the request, which only a full Chrome makes —
`ORCA_MOBILE_WEB_RENDER_BROWSER`, what CI resolves — and reads it off the
server's own log: a favicon fetch comes from the browser process rather
than the page, so Playwright's request events never report one. It also
settles on network idle first, because the fetch comes after the text the
route waited on.

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

* docs(mobile): trim the page-noise comments to the bar

Comment-only. The three `.web` siblings, the document's icon line and
the three override reasons each said their cause once and then said it
again; each now states what the page keeps and why, once.

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

* test(mobile): re-pin the session closure after expo-notifications left

Measured on this head with all five generators run first, against a
scratch worktree detached at the base, which reads the committed pin
exactly: 4271 modules and 1023 local.

    modules        4271 -> 4210   (-61)
    local modules  1023 -> 1024   (+1)

65 modules leave and 4 join. 62 of the 65 are vendored: expo-notifications'
own 55, and expo-application, badgin, abort-controller and event-target-shim
behind them. The other three are the native files the `.web` siblings
replace, so the siblings cost the local count nothing and its +1 is
`host-app-version.ts`, the one new module.

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

* docs(mobile): count the packages the closure note names

The note said 62 vendored modules left and then named five packages
without counts, so the names read as the whole of the 62 and summed to
five. Each carries its own count now: 55 + 3 + 2 + 1 + 1.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 08:07:16 -04:00
Jinwoo Hong 59c0d5585e fix(mobile): the shell swaps the page's client identity so its terminal reaches init (#22201)
* fix(mobile): give the page a client identity so its terminal reaches init

The page's RPC provider answered `getClientId` with `null`, and the session
route refuses `terminal.subscribe` without a client identity. No subscribe
meant no scrollback, so `init` never reached the terminal document: the
surface stayed 0x0 and the document answered every measure with `notReady`.
`canSend` reads the same value, so live input was dead for the same reason.

The identity is the page's shell session, not the pairing credential `init`
deliberately withholds: the host only ever uses `client.id` as an opaque
in-memory key for the mobile input floor and the viewport claim.

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

* fix(mobile): the shell swaps the page's client id for the device's

The host does not read `client.id` as an opaque key: `terminal.send` refuses a
query reply whose id is not the credential the socket authenticated with, and
`terminal-send.test.ts` pins that as a spoof. So a page-chosen id was wrong on
that path however stable it was, and the session id f29a7d1958 used was wrong
twice over — it rotates per mount, and the composer's send journal refuses a
retained operation whose caller fingerprint moved.

The page now claims one fixed placeholder and the shell, which owns the socket,
swaps this device's real identity in as the frame leaves it — in both doors to
the client, the request path and the stream start. Declared on `init.accepts`,
which is optional and additive, so no protocol version moves and a page served
by a shell that does not swap claims nothing rather than sending a placeholder
the host would refuse.

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

* fix(mobile): refuse an unresolvable page client identity at the door

Stripping `client`/`mobileClient` forwarded a request the page never made: the
host reads a missing `client` as a different caller, so a shell that could not
read its own identity would degrade silently instead of failing. Both doors now
answer the page with `bridge_client_identity_unavailable`, which reaches the
caller as an ordinary rejected RPC and reaches the desktop as nothing at all.

The substitution narrows through a checked `isRecord` guard, so the module
carries no type assertion but `as const`.

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

* test(mobile): repin the session route's page closure for the client identity

`bridge/bridge-page-client-identity.ts` declares the placeholder the page's RPC
provider claims, so `client-context.web.tsx` imports it and the module joins the
session route's closure. Measured on the merged head with all five generators
run first, not summed: modules 4269 -> 4270, local modules 1021 -> 1022.

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

* fix(config): charge a JSON stream event to the shell double's window

The JSON emitter took a slot on `unacked` without adding its bytes to
`unackedBytes`, and the `ack` arm subtracts whatever it finds there. One ack
drove the ledger negative, and the binary emitter reads that floor, so every
later window check admitted frames the real BridgeHostSubscriptions would have
refused. The docblock's "on the same ledger" now holds.

A reader for the window comes with it, because the ledger is a closure variable
and the only thing a check could see before was a posted-or-dropped verdict.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:46:12 -04:00
Jinwoo Hong da1c322b00 feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193)
* feat(mobile): one build-time constant decides native or OTA, default native

EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in
preferences.ts. Expo's babel preset inlines a literal process.env member
expression at build time, so a release bundle carries the answer as a constant
and anything but the exact string 'ota' — unset, empty, a typo — is native.
Every default build is therefore the native app, unchanged.

mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to
mount the page comes from the build and never from storage: a native binary
installed over an OTA one, same bundle id and same data container, still refuses
a stored 'true' without reading the key. An unset key reads on only in an OTA
build; a development build keeps its opt-in, and a stored 'false' wins
everywhere so the Troubleshoot toggle can switch an OTA build back to native.

That toggle now mounts wherever the flag can be on, which is the only way back
to the native screens in an OTA build, and its label names the build kind rather
than saying "(dev)". The bundle probe row beside it stays development-only: it
fetches.

The flag census gains two rules — one module reads the switch, in the member
form Expo inlines and not the bracket form, and one named function answers the
build kind — and the build-kind fence now lists the Troubleshoot route that asks
it. Docblocks that said a store build can never mount the shell now say it
mounts only when built for OTA.

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

* ci(mobile): one workflow input picks the shell, and no input means native

Both release workflows gain a `shell` workflow_dispatch choice, options native
and ota, default native, and hand it to the step that bundles the JavaScript as
EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and
the fastlane build_and_upload step on iOS; nothing else in either file sets it.

A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'`
yields native for them — the first OTA release is a dispatch with one field
changed, and every other run is the app we ship today.

Each build step prints the value it is about to build with, read back from the
same variable rather than from a second copy of the expression, so a run's log
cannot claim a shell the build did not use.

The new contract test evaluates that expression rather than matching its text:
absent, empty and 'native' all resolve to native, 'ota' to ota, and any
expression shape it cannot evaluate is a failure rather than a pass.

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

* build: the desktop packages the real page, and the placeholder is retired

build:mobile-web now runs the app builder and app verifier, and both take their
output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each
carrying a constant of their own — one definition of where the bundle lives, so
a drift cannot leave electron-builder's beforePack looking at an empty directory
while the builder reports a tree it wrote elsewhere. build:mobile-web:app is
gone; it was the same two commands.

src/mobile-web/ and its two scripts go with it. What the app builder shared with
them is split into three modules named for what they hold rather than for the
bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the
canonical asset serialization, buildId, hashed assets, the protocol window and
the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two
failure modes are Windows paths and symlinked entries), and
mobile-web-source-line-endings.mjs (the CRLF guard, now with a required
directory rather than a default pointing at the deleted tree).

The two suites that only needed *a* valid tree on disk — the beforePack guard
and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree
instead of bundling the whole mobile graph. It goes through the same manifest
writer the page does, so a manifest shape change still reaches them.

Also retired: the placeholder's tsconfig project and its typecheck lane, its
knip entry, its electron-builder exclusion and .gitattributes pins, and the
app-bundle test that asserted the shims stayed out of a builder that no longer
exists. pr.yml's page job builds the same bundle the package job ships.

Inert for native phones: they never fetch it.

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

* style(config): one import of node:fs/promises in the entry-detection suite

The changed-code quality gate's focused plugins read the two as a duplicate
import; the readFile line was left over from the split.

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

* docs: the comments that still describe the retired placeholder bundle

The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app
and shipped by nothing. That script, that directory and that fact are all gone:
it is built by `build:mobile-web` into the packaged bundle dir, and a phone
mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota.

Two Windows cache keys explained themselves by naming src/mobile-web and "the
two bundle builders"; config/** now covers the builder, the verifier and the
manifest writer, and the spike's key no longer waits on a Phase C flip that has
happened. The keys themselves are unchanged.

Three scratch directories in the app-bundle suites and one in the verifier still
spelled the retired output root. Renamed to mobile-web, which is what the build
writes; they are temp subdirectory names and nothing reads them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:18:33 -04:00
Jinwoo Hong 9a25e318f6 fix(mobile): write live-input text through a seam the page can honour (#22189)
* fix(mobile): write live-input text through a seam the page can honour

On React Native Web a TextInput ref is the DOM node itself, so
`setNativeProps` does not exist. The two terminal live-input hooks called
it on `liveInputRef.current`, and the session route reaches one of them
from a mount effect, so the write was a TypeError that faulted the whole
page: the shell tore the view out, the terminal surface stayed 0x0, the
IME never opened and the accessory bar did nothing.

Both now write through `terminal-live-input-text-write.ts`, whose `.web.ts`
sibling sets `value` on the `<input>` or `<textarea>` RN Web renders. That
covers the case React has no commit to make, which is where an interrupted
IME composition leaves the field.

The browser oracle is new: the session render check cannot reach this
defect, because its shell double answers no RPC, so no terminal handle
exists, `liveInputEnabled` is false, the field never mounts and the write
is skipped by its own optional chain. The new check mounts both hooks
against a real field on the page bundle instead.

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

* test(config): fence every setNativeProps write with a web sibling

Fenced by absence rather than by a list of approved callers: any mobile
source module that calls `setNativeProps` must ship a `.web.ts(x)` for the
page bundler to resolve in its place, and no web sibling may make the call
itself. Run against the two hooks as they were, it names both.

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

* docs(mobile): trim the live-input seam's comments to what is not obvious

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

* fix(mobile): move the settings scroll lock into a seam, and fence the route tree

The census scanned `mobile/src` only, so it read the terminal-settings
screen's `setNativeProps({ scrollEnabled })` as absent. It scans `mobile/app`
too now, under a stricter rule: a route file may not make the call at all,
because expo-router registers a `.web.tsx` beside a route as a second route
rather than as a platform sibling, so a route cannot own a platform split.

The one offender it named moves into `src/terminal/terminal-settings-scroll-lock.ts`,
beside the styles module that already owns this screen. Measured rather than
assumed: RN Web puts an `HTMLDivElement` in the ScrollView ref with no
`setNativeProps` at all, and drives the scroller's overflow from a generated
class with nothing inline, so the sibling locks with an inline `overflowY`
and unlocks by clearing it. `overflowY` rather than `touch-action`, which
stops a touch drag but leaves the wheel scrolling.

The seam does not enter the session route's closure — terminal-settings is
not under `app/h` — so the module pin is unmoved at 4270, re-measured.

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

* test(mobile): read the absent setNativeProps with `in`, not Reflect.get

`in` walks the prototype chain, so the claim that RN Web has no such method
to inherit is the same one, and the anti-slop gate has no dynamic read to
object to.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:15:34 -04:00
Jinwoo Hong faf7567df5 fix(mobile): the catch-all carries a deep link's query, and reads a trailing rest-segment pattern (OTA phase D, C8 carry-forwards) (#22180)
* fix(mobile): carry a deep link's query through the catch-all switch

The catch-all built its pathname from the `page` segments and dropped
everything else, so /h/x/session/y?tab=files reached the page as
/h/x/session/y. Every named switch passes its params explicitly; this one
cannot name the keys, so it takes everything useLocalSearchParams merged
in that is not one of the two segments the pathname is built from.

firstParam per key, as the named switches do, because init.route.params
is one value per name and a repeated key would cross as `a,b`. An empty
value is kept: a named switch drops its own because it knows what its
screen makes of one, and this switch knows no screen. Over the schema's
param ceiling shellScreenRoute answers null and the switch refuses, which
is the same fail-closed answer it gives a pathname the bridge refuses.

Red first: five of the seven new cases fail on the old body — the carry,
the empty value, the repeated key, the encoding round trip through
shellScreenRouteKey, and the ceiling refusal.

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

* feat(mobile): read a manifest route pattern with a trailing rest segment

matchesRoutePattern was segment-count exact, so `/h/[hostId]/[...page]`
read as an ordinary dynamic segment and matched exactly one segment.
Nothing on the desktop declares such a pattern today; a desktop that
ships a catch-all screen later writes one into its manifest, and a phone
already in the store has to read it, which is what OTA is for.

A trailing rest segment now matches one segment or many, never zero, and
never an empty one. One or more and not zero is expo-router 55.0.18's own
answer for the same pattern: getReactNavigationConfig.js turns [...page]
into *page, fork/getStateFromPath-forks.js turns that into ((.*\/)), and
cleanPath beside it gives every path a trailing slash, so the tail must
hold a slash of its own. Measured against both functions directly.

A rest segment anywhere but last, or a second one, is refused by name
(rest-segment-not-last / rest-segments-repeated) and matches nothing, so
such a route stays native rather than being guessed at.

grantsForRoute now prefers an exactly declared route over a rest one for
the same pathname: a rest pattern covers everything under its prefix, and
first-declaration-wins would hand a screen with its own row whatever the
catch-all asked for.

No schema change. Both manifest readers already accept the pattern string
-- the host's ROUTE_PATHNAME_PATTERN allows brackets and dots, and the
phone's is a bounded string -- and that is pinned rather than assumed.

Red first: seven of the twelve new cases fail on the old matcher.

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

* docs(mobile): name the one place the rest-segment rule narrows expo-router

Its own matcher takes a non-trailing rest — h/*page/tail matches
/h/a/b/tail at 55.0.18 — and this refuses it. Written where the refusal
is, so the divergence is read when it changes.

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

* fix(mobile): drop the fragment key from the catch-all route's query

expo-router's parseQueryParams seeds params['#'] from the URL fragment
before it reads the search string, so /h/x/session/y#files reached the
switch with a `#` key and crossed as params: { '#': 'files' }. A fragment
is not a query and the route the page is given carries none.

Red first: both new cases fail on the old filter — the fragment-only link
lands a params key at all, and the mixed one carries '#' beside `tab`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 05:20:46 -04:00
Jinwoo Hong bfcbc6feee fix(mobile): contain the older-history page's rejection at its own boundary, and re-record its family (#22179)
* fix(mobile): contain the older-history page's rejection at its own boundary

`loadEarlier` in `use-mobile-native-chat-session.ts` runs its read inside a
fire-and-forget `void (async () => {...})()` with a `finally` and no `catch`, so
every throw inside it reaches the document as an unhandled rejection. Two
mechanisms, both certified by
`matrix-session.native-chat-page-nativechat.readsession-1`:

- The awaited `nativeChat.readSession` request rejecting — a transport drop
  (`transport failure`, and the empty-message shape) or the client abandoning
  the in-flight page at teardown (`Connection closed`).
- `'error' in result` on an accepted success whose result was absent or null.
  The operation's reader is `z.unknown()` on purpose, because the reply is a
  union, so `rpcSuccessResultOrSkip` accepts `undefined` and `null` as results
  and `in` throws a TypeError on both before the page is ever read.

Same class as the diff-comments fix (#22111) for the first mechanism and the
catch goes at the same boundary — nothing awaits this promise, so there is no
caller a swallowed rejection could hide from. It differs in the second: that
TypeError is a defect in the payload read rather than an uncaught rejection, so
it is fixed by checking the shape before `in` rather than by the catch, which
would only have hidden it.

Neither mechanism moves an observable value: the `finally` already cleared
`loadingEarlier`, and both new returns land where the throw did, before the
window is touched. A refused page and a failed page now leave the delivered
window alone, which is this operation's declared skip policy.

`mobile-native-chat-page-rejection.test.ts` captures the process
`unhandledRejection` handler for the run and reads it; the three cases fail on
main with exactly `transport failure` and the two `Cannot use 'in' operator`
TypeErrors.

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

* test(mobile): re-record the corpus without the older-history page's rejections

The corpus certified the five unhandled rejections the commit before removes, so
the golden had to move with them. Scoped the same way as #22111: `baseline`
bumped by editing that one line, then `--record`. The recorder refuses to run on
any other tree, and the pin guard's `reproduce` replays the corpus against the
pinned tree with only the recorder overlaid, so a product fix cannot be recorded
without the bump.

788 files, 824 insertions, 938 deletions. Every line classified:

- `baseline`, 787 files (786 goldens + `pilot-scenarios.json`) and nothing else
  in any of them. One distinct changed line across all of them.
- `matrix-session.native-chat-page-nativechat.readsession-1.json`, the only
  golden with a substantive change (37 insertions, 151 deletions). Its value
  pool goes 34 -> 24: ten entries leave, none is added, and no surviving entry's
  content changed.

Inside that golden, three things move and each follows from the effects going:

- **Effects.** Five `unhandled-rejection` values leave the pool — the two
  TypeErrors (`02fb8ef75b28` undefined, `74a36d9b608f` null), the two transport
  rejections (`b5f9fdfa5b32` transport failure, `b388251f574a` empty) and
  `14095708382e` Connection closed — and the 16 checkpoints that carried them
  now read `"effects": []`: four each on `result-absent`, `result-null`,
  `transport-rejection` and `transport-rejection-no-message`.
- **One checkpoint is gone.** `native-chat-page-earlier.prelude:cleanup`.
  `run-recording.ts` emits `cleanup` only when teardown produces an effect, and
  the abandoned page's `Connection closed` rejection was the only one it
  produced. Nothing left to observe, so no checkpoint. The two values only that
  checkpoint referenced leave with it: `980096177097`, its rejected
  `nativeChat.readSession#1` settlement, and `91a7795fa0ab`, its
  `nativeChat.unsubscribe#1` frame.
- **Renumbering, on `payloads` at 12 checkpoints.** Ordinals are one counter per
  run, so dropping an effect shifts everything after it: `nativeChat.subscribe#2`
  5 -> 4, `nativeChat.unsubscribe#1` 6 -> 5, `nativeChat.unsubscribe#2` 7 -> 6.
  Each shifted frame is byte-identical to the entry it now points at apart from
  the ordinal, and every one of those entries was already in the pool — the arms
  that never rejected recorded them. That is why nothing is added and the three
  higher-ordinal twins simply go unreferenced.

`state`, `sender` and `settlements` are unchanged on every surviving checkpoint,
so no observable value moved. `recorderSha256`, `adapterSha256` and
`scenarioSha256` all hold, and `HEAD_EFFECT_SHA256` in
`mobile-session-route-parity.test.ts` does not move: that pin walks
`MobileSessionRouteScreen` and its named expansion hooks, and
`useMobileNativeChatSession` is in neither set.

`baseline` is a branch commit, so a repin to main follows the squash.

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

* test(mobile): build the page-rejection client double with one assertion

The changed-code quality gate read two assertions in the new file and the
second carried no rationale. There is only one shape here the types cannot
express, so there is only one cast: `RpcSuccess` requires `result`, which is
what makes the matrix's `result-absent` reply untypeable. The sender goes into
the object literal the cast already covers.

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

* test(mobile): repin the corpus to the head its product tree matches

The commit before moved a file under `mobile/src`, and the recorder guards that
whole tree rather than only what it loads: `scripts/rpc-recording.mts` refuses
to run when `git diff <baseline> -- mobile/src src/shared` is non-empty. Left
alone, the pin would name a tree that still reproduces the corpus but that no
one could record against without repinning first.

788 files, 788 insertions, 788 deletions, and `baseline` is the only distinct
changed line in any of them. Nothing else moved, which is also the determinism
check: a second full record against a product tree that differs only by a test
file the drivers never load reproduced all 787 goldens byte for byte.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 04:39:06 -04:00
Jinwoo Hong ed909846f6 test(mobile): an offline cold start reads the grants of the last manifest accepted (OTA phase D, finding 7) (#22175)
* refactor(mobile): share the generation store's in-memory disk

The fake answers the file-system port the store is written against, and the
offline-grant suite drives the same real store against it. Two in-memory trees
answering one port differently is how a passing suite stops being evidence.

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

* test(mobile): an offline cold start reads the grants of the last manifest accepted

Finding 7 on #21503 end to end: the reducer, the runner, the real generation
store and a disk, with only the network and the platform modules doubled. A
grant-only edit keeps the bundle's build id, so the cached assets are right
under a manifest an edit behind; the verdict measured here belongs to a second
mount whose whole evidence is the tree on disk.

Both directions of the edit and both arms: a widened grant list reaches the
offline verdict, a revoked one is gone from it, and a manifest read under a wall
is not written, so offline keeps the last grants this shell could use.

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

* fix(mobile): name the extracted fake's type at its import

The store suite still spelled the local type name. `tsconfig.json` excludes test
files, so only the tests-typecheck ratchet sees this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 03:51:35 -04:00
Jinwoo Hong 48bdbb8e24 refactor(mobile): the host-scoping rewrite moves out of the terminal's HTML (#22172)
`scopeDocumentStyleToHost` and `scopeStyleToHost` are one rewrite of a flat stylesheet, and two
page mounts read it: the terminal's and the rich Markdown editor's. The module lived under
`terminal/terminal-webview-html/`, so the editor reached across the terminal's directory for it.
It moves to `src/style-scoping/`, named for what it does rather than for its first caller, and
both mounts import it from there. No re-export shim: the old path is gone.

Its test does not follow it whole. Four of its five cases read the terminal's own sheets
(`TERMINAL_DOCUMENT_*`, `XTERM_ENGINE_CSS`) and the first asserts `document-style.ts`'s split
identity, which is not about the rewrite at all -- so that file stays in the terminal directory as
`document-style.test.ts`, beside the module it is about. What moves is the part that names no
caller: which selectors read as the document's own, and the sheet shapes both exports refuse.

The page-closure pin names the module by path and is updated in the same commit. The closure is
otherwise unmoved: 4,363 modules and 1,021 local before and after, one path swapped for another.
The five generated artifacts are byte-identical -- none of them bundles this module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 03:44:35 -04:00
Jinwoo Hong 895f2cf477 test(mobile): the web app's script fence is re-derived from a measured sweep (OTA phase C, C7.8) (#22152)
* fix(mobile): re-derive the web app script fence from the measured spread (OTA phase C, C7.8)

`4r + 16` was a guess at break-even, and its slack ran from 17 scripts at one
route to 4 at thirteen -- loosest where nothing is and tightest where the tree
actually sits. Re-measured by building every prefix of the sorted route key
list: 15 routes emit 67 scripts, and the marginal cost of a route runs 1 to 9
depending on what it shares, so no line through the route count is both an
upper bound and a budget.

The sweep is now the fence's only input. The envelope is the measurement plus
one margin at the swept tree, growing by the worst route the sweep saw for
every route past it, so a new route breaches it only by costing more than any
route measured. The margin is four, which is the most the count has been seen
to move at a fixed route count with no route added: the head that wrote the old
fence read 32, 43, 61 and 69 at 8, 10, 12 and 14 routes where this one reads
34, 44, 57 and 65.

Two-sided in the test, which is what stops the next bump: a build more than the
margin under the envelope fails there too, so the fence has to be re-measured
rather than raised. The route count stays the only term and the mermaid control
still tells one artifact from 172 chunks.

The shell-fit crossing comes in from 50 routes to 31 with it, because the
envelope grants the worst swept route where `4r + 16` granted four. Byte budgets
re-read from this build and unchanged: 8,053,438 of 9,437,184 total, 1,612,006
before the first route of the 3 MiB allowed.

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

* fix(mobile): spell the session route's grant counts off the table (OTA phase C, C7.8)

#22072 deleted `native.wakelock.set` from the session route and left both prose
counts behind: "Fourteen grants" for a list of thirteen, and "the four audio
verbs" for the three that remain. The all-or-none reason went stale with them --
it argued from a screen free to lock, which is the verb that was removed, and
the device side has owned that lock since. It now argues from the microphone a
route granted two of the three cannot close.

The census beside the route-declaration test is what stops the next one. It
reads which number words appear before "grants", "audio verbs", "media verbs"
and "or none" anywhere in the file and compares them with the list itself, so a
second spelling left in place fails rather than passing on the first correct
hit, and a comment rewrapped at a different column still matches.

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

* test(mobile): the four sibling comments spell their grant counts off the tables too (round 1)

#22072 removed `native.wakelock.set` from the session route and left the count
standing in four more files than the route table: "fourteen grants" in the
call-site census and in the hop census, "the four audio grants" in the census
and its test, and "the four audio verbs" on the bridge schemas. Each is now
counted off the table it describes rather than copied beside it.

The six-versus-eight split in the call-site census could not be re-derived --
its own "those eight" never summed to the six rows plus the audio grants, so it
was wrong before #22072 too. It is replaced by what the tables say today: six
rows pin eight of the session route's thirteen grants, and the other five have
censuses of their own. Both numbers come from `PAGE_GRANT_CALL_SITES` and the
route list, and the existing case that names six rows covering eight grants is
what holds them.

The census the route table got is now `spelled-count-census.mjs`, driven from
three files instead of one. A phrase restated in a file collapses to one claim,
so a header and a test name may spell the same count; a phrase with no number
before it reads as the empty list, which no table count matches.

The bridge test is the one site with no count left to pin: it names
`BRIDGE_NATIVE_VERB_NAMES` instead, and a new assertion holds its own
`AUDIO_VERBS` equal to that table's audio rows, so the cases below cannot pin
one set while reading as coverage of another.

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

* test(mobile): count the session sentence off the session intersection (round 2)

`'of the session'` was counted off every grant in `PAGE_GRANT_CALL_SITES`, which
is not what the sentence it pins is about. A row pinning a grant no route
declares -- the shell can serve a verb before a screen asks for it -- would have
made the census demand the comment overstate what the session route has.

Proved before fixing: adding `native.share.send` to the navigate row moved
`pinnedHere` from eight to nine while the session intersection stayed at eight,
and the census failed asking for "nine of the session route's thirteen grants"
against a sentence that was right to say eight. With the intersection it passes
under the same probe, and the failure moves to `'grants this file pins'`, which
is about this file's rows and does correctly demand nine.

The other three rows are left as they were, for the same reason read the other
way: `'grants this file pins'` and `'did not'` are about the rows here, so they
keep the full list, and `'have censuses of their own'` and
`'are not repeated here'` already count the session grants no row pins.

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

* test(mobile): pin the two counts in this file's it titles too (round 3)

The census pinned the JSDoc counts and stopped there, so "names six rows
covering eight grants" and "reaches every one of the eight through the session
route" were free to go stale. Shown rather than argued: with a ninth grant on a
row and the header corrected to nine the way the old census forced, the suite
went green with both titles still saying eight.

`'grants this file pins'` becomes `'grants'`, which reads the header and that
title as one claim -- they are the same number, and a row per site would have
let them disagree while both passed. The session title takes the intersection,
for the reason round 2 gave.

Every other spelled number in the file is not a count of a table: "the two
tables" is how many sources the census reads, and "the one it reads", "a new one
cannot be missed", "any one grant went missing" and "every one of" are
quantifiers with no table behind them.

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

* test(mobile): count the session-route title off the rows it asserts over (round 4)

`'through the session route'` took the intersection, but the title it pins heads
an assertion that compares `grantsNeeded` with every row's grants. The title
names the session route and is not a claim about it: it says the rows here are
all reached through that route, so it moves when the rows move.

In the divergence the JSDoc already described, the two parted. With a ninth
grant on a row the assertion compares nine while the census held the title at
eight and passed, leaving a title reading below the assertion under it.

Exactly one row takes the intersection now, the `.mjs` sentence for how many of
the session route's grants these rows cover, and the JSDoc says so rather than
describing a rule with two members.

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

* test(mobile): two wording fixes, and the byte readings re-measured on the merge (round 5)

pullfrog on the census JSDoc, both correct. "make the census demand overstates"
was a finite verb where the bare infinitive belongs. "Every other row counts the
rows here" was contradicted by its own table: the rows reading the route's list
and the rows counting grants no row pins take neither count, so the sentence is
scoped to the rows that choose between the two and says what the rest read.

The two byte readings in the fence doc are re-measured on the merged tree, since
they name a head: 8,055,568 of 9,437,184 total and 1,612,253 before the first
route. C8.1 added no route, so the sweep and the envelope are untouched and the
tree still builds 15 routes into 67 scripts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 02:43:35 -04:00
Jinwoo Hong e47ef8cc28 feat(mobile): the shell tells a page which optional capabilities it has (OTA phase D, C8.1) (#22141)
* fix(mobile): publish page-route pairs the strict host schema accepts (OTA phase D, C8.1)

`routeViewOf` handed the manifest's own route entries to the host as
`pageRouteGrants`. The phone reads a manifest route loosely, so an entry
arrives carrying whatever field the desktop that wrote it knew about, and
`BridgePageRouteGrantsSchema` is `.strict()`: one unread key refuses the
pairs, `createBridgeHost` refuses the route with them, and the page gets no
`init` at all rather than losing one field.

Fixed before any route carries an optional grant (ruling 37.4), so the
manifest field the next commits add costs an installed shell nothing.

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

* chore: drop the closure and bundle probe scripts from the tree

Scratch measurements for C8.1 (which route closures reach the HTML preview,
and what the preview render rig costs to bundle with a client provider). They
belong outside the repository and were swept in by the previous commit's
`git add -A`.

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

* feat(mobile): a manifest route may declare optional grants (OTA phase D, C8.1)

Design B of design-ota-c8-1.md, ruling 37. `MobileWebBundleRouteSchema` grows
`optionalGrants` under the required lane's own grammar, with the 16-name
ceiling applied over the union of the two lists rather than to each. Serving a
route still reads `grants` alone, so a capability a screen cannot work without
stays required and takes the route native; a session's granted list is
`[...grants, ...optionalGrants]` narrowed to what this shell implements, from
one helper that both `grantsForRoute` and the `pageRouteGrants` publish read.

The ruling's compatibility rationale is corrected in place. `z.looseObject`
passes unknown members through rather than dropping them (measured, zod
4.4.3), so a shell older than the field still receives the key; what it lacks
is a policy that reads one. What makes the lane safe against such a shell is
therefore the previous commit's publish fix, not the reader.

BRIDGE_PROTOCOL_VERSION stays 1. No new notify, verb or frame field.

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

* feat(mobile): name the shell's cancelled-navigation behaviour as a grant (OTA phase D, C8.1)

`externalNavigation` joins `MOBILE_WEB_SHELL_GRANTS` beside `screencastBinary`
and `haptics`, declared in `cancelled-navigation-target.ts` because that is
the module holding the rule which acts on it. A third token that is neither a
verb nor a notify: the page posts nothing to make a cancelled top-frame
navigation happen, so this list is the only thing that can tell a page whether
a tap inside the sealed HTML-preview frame escapes at all.

A constant and not a platform read (ruling 37.1): both engines dispatch the
event, `ios/MobileWebShellView.swift:481` and Android's
`MobileWebShellView.kt:382`, so an app build carries the behaviour on both or
on neither.

The policy census grows the half that was only pinned by the verb table: the
implemented set is that table plus exactly three non-verb tokens, each read
off the module that declares it.

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

* feat(mobile): the bundle builder carries a route's optional grants (OTA phase D, C8.1)

`resolveMobileWebPageRoutes` maps each declaration member by member, so a
field the declaration grows reaches a phone only once the map names it: until
now `optionalGrants` would have been dropped in silence and every route would
have declared nothing optional. Omitted when the route declares none, because
absent and empty are the same answer to a shell.

The declaration suite grows the rule rather than a row: the map carries the
lane through and writes no key without one, and the lane is held to the
manifest's own grammar and to the ceiling over the union of the two lists.

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

* feat(mobile): the HTML preview hides its links on a shell that cannot open one (OTA phase D, C8.1)

The session route declares `externalNavigation` on the optional lane, and the
preview asks for it before it renders an artifact's links as links. Ruling
37.2's three readings are what "hide" means here, and removing `href` is what
delivers all three at once: `a:any-link` stops matching, so the UA stylesheet
stops underlining, the element leaves the tab order, and there is no dead
anchor a tap does nothing on. The text the author wrote stays where it was,
the artifact paints, and the Preview/Source toggle is untouched.

Done with the browser's own parser rather than over the source text: an `href`
inside a comment or a `<template>` is text to a browser, and a pass that
rewrote either would be editing the artifact instead of its links. The frame
also loses `allow-top-navigation-by-user-activation` on that path, so a link
the pass somehow missed is refused by the browsing context as well.

One route, measured rather than assumed: the design said two, and the file
preview route's closure does not reach the HTML preview at all - it renders
`MobileFilePreviewScreen`. The new closure census derives that list from the
hook's callers.

The render rig grows the case on both engines and the readings it needs, and
`mobile-web-app-preview-frame-readings.mjs` is split out of it at the
readings/arms boundary, because the two were over the 600-line cap together.
Two engine findings are recorded in the rig: an `<a>` with no `href` still
answers `tabIndex` 0 on both, so focusability is asked by focusing; and WebKit
computes `cursor: auto` for a real link, so that reading is pinned where it
discriminates and its blindness pinned where it does not.

The hop-coverage census now reads the effective set, because that is what the
running rule compares. Inert today: the session route is the only declarer and
an opener into every other route.

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

* test(mobile): pin the preview's hidden link path where the unit suite can reach it (OTA phase D, C8.1)

The mobile suite runs in a `node` environment whose resolver has no `.web`
precedence, so `MobileHtmlPreview.web.tsx`'s import of the grant hook lands on
the native sibling, which answers yes unconditionally. That is why the
existing component suite still measured the granted frame without knowing a
grant exists, and it means the hidden path had no coverage in the sharded
`test` job, where the render rig is skipped for want of the bundler's
dependencies.

So the wiring gets its own file with the module replaced: that the component
asks, and that both the frame's sandbox and the document it is handed follow
the one answer. happy-dom rather than the suite default, because the inerting
pass parses with the browser's own `DOMParser`.

`String(node.type)` rather than a literal comparison: `node.type` is
`ElementType`, which overlaps a real intrinsic tag and not the host strings
these mocks render, so `=== 'Pressable'` is a no-overlap error under
`tsconfig.test.json` and the tests-typecheck ratchet reds on it.

Also replaces a `Reflect.get` the anti-slop gate refuses with an `in` check.

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

* test(mobile): repin the session page closure at 4,362 for C8.1's three modules

Measured on both sides with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base
`841d06a969` with all five postinstall generators run first, and the two
`local` lists diffed rather than the total inferred: 4,359 -> 4,362 modules,
1,017 -> 1,020 local.

All three are local source modules and none is vendored: the page's read of
`init.grants.native`, the pass that turns an artifact's links back into text
without the grant, and the module declaring the token beside the rule that
acts on it - reached both by that hook and by `page-route-policy.ts`. The
`bridge-caps.ts` it imports was already in this closure, and the hook's native
sibling is replaced rather than joined.

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

* test(mobile): allowlist the preview's grant sibling among the .web.* overrides

`mobile-web-app-web-overrides.test.mjs` pins the allowlist against the `.web.*`
files on disk, so a new web sibling reds it until the file says why the page
needs one. Red before: `expected [ …(36) ] to deeply equal [ …(37) ]`, naming
`src/components/use-html-preview-link-grant.web.ts`.

The preview's own entry is corrected with it: its reason said
`allow-top-navigation-by-user-activation` is granted, and that token is now
conditional on the shell answering that it can open such a navigation.

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

* test(mobile): the hidden-link render case waits on the frame's own reading (round 1)

CI's chromium arm timed out at the full 240 s on this case alone while the
WebKit sibling passed in 1.5 s and it passed 26/26 locally. The cause is the
third arm: it tapped the granted link and waited through
`expectNavigation: 'main-frame'`, and `waitForRecordedNavigation` has no bound
but the case's own timeout. Under CI load the click missed its 2 s
actionability window, no navigation was ever recorded, and the arm sat in that
wait until vitest gave up - `recorded []`, with the frame attached only at
38.9 s. Three arms sharing one budget is what made this the case to find it.

The arm is dropped rather than its wait lengthened or retried. Every verdict
left is a reading the frame itself publishes: the anchors its document holds,
the style the engine computed for one, whether focus lands on it, and now
whether the tap this arm made landed at all - `actError` is asserted null, so
a click that never reached its target is no longer the same three zeros as a
tap that did nothing.

Nothing is lost. The tap's outcome on a granted shell is the next case, on
these same counters from this same rig and with a budget of its own, which is
the presence precondition this file already uses elsewhere for the same
reason. The WebKit sibling's discriminating reads are untouched.

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

* fix(mobile): the inert-link pass changes nothing an engine renders but the links (round 2)

Round 2's ruling: the hidden-link path may change nothing about the artifact's
rendering except that links are not links. A parse and a reserialise is not
free of that by default, and all four findings reproduced on Chromium 147 and
WebKit 26.4.

A same-document fragment link is kept. It starts no navigation at all, so it
goes on working inside the sealed frame whatever the shell can do, and taking
it away would be degradation over a capability it never needed - an artifact's
own table of contents is the case. Its `target` still goes, because a fragment
aimed at another frame is a navigation rather than a scroll, and `href=""` is
not a fragment: it resolves to the frame's own URL.

Links inside `template.content` are reached, recursively. `<template
shadowrootmode>` is a declarative shadow root the frame's parser attaches and
renders, and `querySelectorAll` does not walk into template content, so those
links arrived live inside a sandbox that refuses their navigation - the dead
anchor ruling 37.2 forbids. Measured: `parseFromString` attaches no such root
on either engine or in happy-dom, so the pass can reach them.

The leading newline of a `pre`, `listing` or `textarea` is written back. A
parser drops one after the start tag and the serialiser is specified to put it
back; measured, neither engine's does, so a round trip lost a blank line from
every such block.

The doctype is carried whole, and the reason is corrected from the one the
finding gave. It cannot move this frame between layout modes: a `srcdoc`
document takes its mode from its embedder, and measured, a quirks doctype, the
bare name and no doctype at all all read `CSS1Compat` inside the frame. What
rewriting it does is change the document the author wrote for no reason, with
`document.doctype` observable beside a Source tab showing the original. The
render case pins `compatMode` as the blind reading it is and reads the frame's
own doctype identifiers as the one that discriminates.

Option B was not available: the frame has no `allow-scripts` and inherits
`script-src 'self'`, so nothing runs inside it and there is no injection to
carry the work.

Also drops a vacuous half of the affordance test. `renderSource()` is called
with no argument, so the markup a Source view shows is the caller's own
closure and asserting it equals the fixture passed whatever the component did.
What the component decides is whether the rewritten frame stays mounted
underneath, and that is what is read now.

`mobile-web-app-preview-arm-driver.mjs` is split out of the render rig at the
boundary the readings module already names - the rig holds what each case
claims, the driver how an arm is driven, the readings what it reports - since
the three were over the 600-line cap together. No max-lines disable or bump.

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

* fix(mobile): a fragment link is a frame navigation in this preview, so it is inerted too (round 3)

pullfrog is right, reproduced on both engines before believing it. Round 2
kept `#`-prefixed hrefs on the theory that they are same-document scrolls. In
this frame they are not: the document's URL is `about:srcdoc` while its base
URL is inherited from the embedder, so `#section` resolves against the shell's
own URL and the destination differs from the document's by more than a
fragment - which makes activating it a frame navigation, and the shipped
`frame-src 'none'` refuses it.

Measured under the shipped policy, one tap, with something to scroll:

  Chromium 147   scrollY 0, frame becomes chrome-error://chromewebdata/,
                 artifact gone, embedder reports frame-src <origin>/preview
  WebKit 26.4    scrollY 0, frame stays about:srcdoc and intact, same report

So the destruction is Chromium-only but the absence of a scroll is not: there
was no working affordance to carve out for, and the carve-out left a live link
that destroys the preview - worse than the inert text it was meant to avoid.
Both sandbox values behave the same, so this is the base URL and the policy
rather than the sandbox.

The same tap does the same thing on the granted path, where this pass does not
run, so an artifact's internal links have never worked in the preview. That is
not this change's to fix; it is recorded in
`followup-html-preview-fragment-links.md`, and the render case reads the
granted arm's violation as its presence precondition so the behaviour is
pinned rather than merely known.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 02:11:40 -04:00
Jinwoo Hong 5769eb6724 fix(mobile): a granted microphone tap launches no permission activity, and an aborted start says so (#22150)
* fix(mobile): a granted microphone tap must not launch the permission activity

Every Android mic tap raised GrantPermissionsActivity although RECORD_AUDIO
was granted: Expo's askForPermissions path has no granted check and goes
straight to delegateRequestToActivity. The activity pauses and resumes the
React host for 50-115 ms.

The module now answers from getPermissionsWithPermissionsManager when the
permission is already held. iOS needs nothing: EXPermissionsService returns
on a granted status before it reaches the requester.

The resume behind that activity forces a tabs reconciliation, which can
transiently clear the active handle and so flip canSend, the hook's `enabled`.
That reached cancel() through the !enabled Effect -- the same cancel the user's
own has -- so a tapped start ended at idle with nothing shown. abandonDictation
now carries a reason: null is the user's cancel and stays silent, anything else
reaches onError when a start or a recording was underway.

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

* test(mobile): type the dictation device log instead of asserting it

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

* test(mobile): the input-closed test names what is established and what is open

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

* fix(mobile): a dictation abort must not write over the start that replaced it

abandonDictation read what it would report before awaiting the desktop cancel,
and nothing re-checked identity after it resumed. A start landing inside that
round trip reaches recording, then the stale abort reported the closure over it
or idled it. It now captures the generation it bumps to and returns if the
generation moved, the guard the finish check and the desktop start already use.

capture.open()'s catch had the same hole: a rejection arriving after a disable
had already reported the closure ran applyStatus('idle') and rethrew, wiping it.
The catch now makes the same check the arm below it makes, and returns without
rethrowing, because the composer toasts whatever start rejects with.

Both races predate this branch; probed against b9643365ba with the same cases.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 01:49:22 -04:00
Jinwoo Hong 5064469687 fix(mobile): a same-build cache hit persists the fresh manifest (OTA phase D1) (#22139)
* fix(mobile): persist a fresh manifest onto the generation on disk

A route-grant edit on the desktop moves no asset, so the bundle is
published under the build id it already had: the cached generation holds
the right bytes under a manifest an edit behind, and that stored manifest
is the whole of an unreachable host's verdict.

The store gains one operation for it. Refused unless the manifest names
exactly the bytes on disk — same build id, and the same asset list read
through the contract's own serializer, which is the string the id is a
digest of. Written beside and renamed over, so a write that fails leaves
the manifest the assets were downloaded with. A refusal is a return
value, never a throw: it costs freshness, never the generation.

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

* fix(mobile): write the manifest through on a same-build cache hit

The same-build hit in `onManifestRead` opens the cached generation and
now also asks the store to rewrite the manifest beside it, and the
generation the session holds carries the fresh routes from that point.
Without it every offline verdict lagged a grant edit: `onCacheRead` reads
the stored routes, and nothing on this path wrote them.

The manifest travels whole on `manifest-read`, because the store compares
its asset list and a projection rebuilt from the fields a transition
reads would name other bytes. The fallback fixture that read a newer
manifest under the cached build id now uses a build of its own, which is
what makes the download it is about happen at all.

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

* fix(mobile): every same-build manifest read persists, whatever the route verdict (round 1)

A verdict about this route is not a verdict about the manifest. A fresh
list that takes this screen native, or that names a bundle this shell
cannot open, still grants or revokes the other routes the same assets
serve — and what is stored beside those assets is the whole of the next
offline verdict. Both returns left it unwritten, so an offline entry kept
grants the desktop had already taken away.

The same-build read is now taken before the route verdict: the native
and wall returns carry the fresh routes on the generation they hold and
emit the persist, exactly as the open does. The different-build arms are
untouched, wall included, which still fetches nothing.

`readMobileWebShellReachability` moves to `mobile-web-shell-reachability.ts`,
the module its test was already named for: the reducer was one line under
the 300-line cap and this fold needed the room.

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

* fix(mobile): make the manifest swap crash-safe at the store (round 2)

`moveFile` deletes the destination before it moves, because expo offers
no atomic replace, so a failure after that delete left a generation with
no manifest at all — which reads back as "no activation" and drops the
host's cache. A phone whose host is unreachable loses its whole workspace
to one interrupted rename. The store's fake threw before that delete, so
the suite never exercised the ordering the adapter really has.

The write is now three steps inside the generation directory: the fresh
manifest to `manifest-next.json`, the old one away, the fresh one over
it. The old manifest is never deleted before the whole of the fresh one
is on disk, and `readActive` settles every window it leaves — both files
present finishes the swap, a pending manifest alone is adopted, and one
that is torn or names another build is discarded with the old one kept.
A pending file that cannot be settled deletes nothing, because it may be
the only manifest left and the next read can still adopt it.

The asset-path guard refuses the pending name too, or an asset could be
read back as an activation. `joinUri` moves to a module that imports
nothing: taking it from the file-system module pulled `expo-file-system`
into everything that addresses the cache, which is the import the store's
testability rests on not having.

The refusal rule now names where the id-to-assets refine actually lives.
It is on the host's strict schema, not on the loose reader the phone
parses with, so the store's asset comparison is the phone's own check
rather than a restatement of one already made.

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

* fix(mobile): a walled same-build manifest is not persisted (round 2)

Round 1 wrote every same-build manifest through, the walled ones
included. That is the one read where the fresh manifest must not reach
disk: an offline entry skips the compat check, so a stored manifest this
shell has just declared it cannot read would have the next offline entry
open a page under the grants of that bundle. What is on disk stays the
last manifest this shell accepted, and the held generation keeps its
routes with it, so the session's record still matches what was written.

The native-route arm keeps the write: a route this build cannot serve is
not a bundle it cannot read, and the fresh list still governs the other
routes those same assets serve. Its round-1 test is now a control that
pins the wall persisting nothing.

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

* fix(mobile): the phone refuses a manifest whose build id is not its asset digest (round 3)

The id is a cache key and a claim about content at once, and only the
host checked the two against each other. The phone read the same document
loosely and never recomputed the digest, so a stale or forged id reached
the shell — which treats an id it already holds as the same bytes and
opens the generation on disk without paging one.

The reader now carries the host's refine, so a manifest that reaches the
reducer under the cached build id names the same serialized asset list by
construction. The two checks in that reader are one `superRefine` with
the cheapest first and the first issue returning: the digest is the only
one that hashes, and a manifest already over the allocation ceiling must
not be hashed to be refused. The store's comparison stays, as the second
reading of one rule rather than a rule of its own: its argument is a
plain object, and nothing in the type says which parse it came from.

The fixtures that published a literal id now derive it from the assets
they name, which is what the host does. The one test that needs a single
id over two asset lists still names it, because that collision is what it
is about. Every one of the 13 manifests in the golden corpus was already
digest-correct; the replay suites pass unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 01:17:09 -04:00
Jinwoo Hong 35897da0aa fix(mobile): serialize a list the engine nested inside a paragraph (#22145)
`insertUnorderedList` puts the `<ul>` inside the `<p>` it was given rather than replacing it —
measured on WebKit 26.4 and Chromium 147 both — and `blockMarkdown` read such a paragraph inline.
A bullet list the user typed came back as the paragraph's own text with no marker, so it did not
survive a markdown round trip, on the page and in the native WebView alike.

The serializer now reads structure wherever the list sits: text before it is a paragraph, the list
is a list, text after is a paragraph. The DOM is left as the engine made it and no branch asks
which engine is running. The parse side needs no mirror — it already renders `- x` as a top-level
`<ul>`, which is the shape the fixed serializer reports, and the flat control case pins that.

The unit fixture is built through the paragraph's own `innerHTML`: the HTML parser closes a `<p>`
before a `<ul>`, so a markup string on the editor gives two siblings and would measure the flat
shape. Each case asserts the nesting it got before it reads anything.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 01:11:13 -04:00