Commit Graph
11349 Commits
Author SHA1 Message Date
github-actions[bot] a98314e8bb Update README downloads badge 2026-09-18 12:35:55 +00:00
Jinwoo Hong 47d107cf2e feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435)
* refactor(mobile): say whether a host status was readable, and carry its protocol window

`useHostStatusGates` settled the same closed gates for a host that answered
`status.get` with no capabilities and for one whose status nobody could read:
both paths produced an empty capability list and an `ok` verdict. A caller that
walls on a missing capability cannot tell those apart, and the mobile web
bundle's wall is terminal, so it must never fire for the second.

`statusReadable` distinguishes them. `hostProtocolWindow` exposes the two
protocol numbers the hook already read for `evaluateCompat`, as the reply's own
fields, so the bundle wall can evaluate its own window without a second
`status.get`. Both are additive; no existing consumer changes.

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

* feat(mobile): add the hybrid shell flag and the generation path both loaders demand

`orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same
shape as the terminal autocomplete flag.

`generationDirectoryPath` converts the store's `file://` uri to the absolute
path the native shell view requires: both `MobileWebShellGeneration.load`
implementations refuse anything without a leading slash, and `expo-file-system`
only ever hands out uris.

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

* feat(mobile): the hybrid shell session as a pure reducer

Every decision the route makes, as `(session, event) -> (state, effects)`: the
capability wall, the lazy sweep and cache read, the manifest check, the cached
build-id hit that skips paging, the offline open with no compat check, and the
three recovery rules the native shell view's contract states.

Pure, so the rules are table tests rather than a simulator run. Two latches sit
beside the state because both outlive it: `retriedOnce` spans the delete and
refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced
by a `ready` under a new session id.

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

* feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4)

Wires the four Phase B and A pieces together and adds no decision of its own.
`h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native
`desktop-too-old` wall still applies above it. With the flag off — every store
build, since the only writer is a `__DEV__` Troubleshoot toggle — the route
redirects to `h/[hostId]` and the screen is never constructed, so nothing is
fetched, written or swept.

The runner owns only the impure edges and checks an epoch before every dispatch,
so an unmount, a host change or a retry abandons work in flight and aborts a
download that would otherwise hold four of the host's read slots. The native view
is keyed on the session id, which is what makes the reducer's remount a rebuilt
WebView with every fence reinstalled.

A census test pins who touches the flag: the route reads it, the developer row
reads and writes it, and the key itself lives in one module.

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

* test(mobile): type the hoisted test doubles instead of asserting them

The changed-code casting gate refuses `as` in new code, and these three were
only widening an empty literal. An annotated `vi.hoisted` factory does the same
job under a check.

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

* fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress

Found on a simulator with the paired desktop stopped: the client never settles
on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting`
-> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping
`reconnecting` to "still connecting" left a phone holding a verified cached
generation on `checking` forever instead of opening it, which is the one case
the offline rule exists for.

`connecting` and `handshaking` are the first dial and still wait; everything
else is unreachable. The mapping moves next to the reducer it feeds, because it
is a decision and the runner is supposed to hold none.

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

* test(mobile): build the reachability stub instead of asserting it

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

* refactor(mobile): move the shell session vocabulary into its own module

Pure move, no behaviour: the states, events, effects and gates the reducer
and its runner share now sit beside the reducer rather than inside it, so
the transition rules have room to grow under the file's line budget.

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

* fix(mobile): drop a shell effect result whose flow has been superseded

Every restart of the flow bumps a number the effects of that run are stamped
with, and a result echoes it back: a manifest read still in flight when the
socket drops used to reject after the offline path had already opened the
cached generation, replacing a displayed workspace with a download failure,
and a status refetch arriving mid-check used to run the cache read and the
download twice. The gates restart no longer clears the remount latch either;
only the retry button does, so a reconnect cannot grant a second remount.

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

* test(mobile): census the flag across modules, not just src and app

The native view tree was outside the scan, so a reader added there would
have passed an assertion that reads as exhaustive. Proven by adding one to
the shell view module: the census fails.

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

* fix(mobile): let only the state that mounted the view hear the view

A native batch reports two failures in a row, and the reducer applied both:
document-load-failed started the delete-and-refetch, render-process-gone
then made it terminal without a new flow, and the cache read the recovery
had already asked for dragged the session back to checking behind a failure
screen. A report arriving outside `ready` is from a view that is no longer
on screen, so it changes nothing.

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

* fix(mobile): say a host status could not be read instead of spinning on it

A transient status.get failure settles the gate unreadable and nothing probes
it again, so the route sat on "Checking host" for as long as anyone left it
there and Try again re-read the same settled answer. It now says what
happened and offers no retry, and a status that does become readable picks
the flow back up on its own.

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

* fix(mobile): restart the flow on the verdict that changed, not on every gates object

A reconnect cycle rebuilds the gates several times a second with the same
answer in them, and each one re-swept the staging tree and flipped an offline
screen to a spinner and back. Only a changed verdict restarts now, which is
also why the gates effect has to depend on the host id: two hosts whose gates
read identically would otherwise leave the second session in `checking`.

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

* test(mobile): cover what only the shell runner can get wrong

Three cancellations had no test: the epoch that stops a result reaching a
session that is gone, the unmount cleanup that aborts the download, and the
retry that does both before starting over. Each is now red under its own
mutant. The download also re-checks the abort before it writes, since an
abort landing between the fetch's last read and the commit would otherwise
still put a generation on disk for a screen nobody is on.

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

* fix(mobile): write the shell runner's refs after the commit, not during render

React can replay or discard a render, so a handle written during one can run
effects for a session that never existed. The client and the host cache key stop
being refs at all; the effect handle is committed in an effect above every
effect that dispatches.

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

* fix(mobile): keep the hybrid shell flag unreadable outside a development build

Development and release share a bundle id, and the iOS data container survives
an install-over, so a flag a developer toggled on would follow the store build
in and mount the shell on a deep link. The release read never reaches storage.

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

* test(mobile): drive the route's flag read as each build kind reads it

The route test exercises the real preference read, so it has to say which build
it is. A store build whose container kept a development toggle redirects.

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

* fix(mobile): let a cache read that lands mid-dial wait for the compat check

A connection still being made is not a host that cannot be reached. Opening the
cached generation there skips the compat check the landing connection is what
makes answerable, so only `unreachable` takes the offline path now.

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

* fix(mobile): move the developer toggle only after its write lands

The route reads the flag back from storage, so a switch that moved on the tap
let the open button race the value that was being persisted. The switch and the
button both stay put until the write settles, and a failed write keeps the
previous position.

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

* test(mobile): say which build kind a test runs as without asserting on globalThis

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

* fix(mobile): undo a staged generation the abort reached before the commit

The commit is the write staging cannot take back: it renames into the active
slot and moves the host index. An abort landing while the bytes were being
staged now removes the staged tree instead of activating it.

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

* fix(mobile): open the cached workspace when the link, not the bundle, cut a read short

An RPC rejection can reach the reducer before the reachability change does, so
the offline gate never fires and a phone holding a valid generation reads that
the workspace could not be downloaded. A read that failed on the link now opens
what is on disk, the same path offline takes; a verdict about the bundle, from
the host or from the bytes, still fails.

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

* fix(mobile): send a hybrid shell recovery through the same gate a start takes

A view failure deleted the host cache and went straight back to the manifest
check on whatever gates the ready session happened to be holding. Gates that
arrive while a generation is on screen are stored without restarting, so after a
reconnect whose status probe failed a ready session carried statusReadable false
and an empty capability list, and the recovery's manifest check walled the host
as bundle-unavailable: terminal, no retry, about a host that never answered.

The gate is now one verdict both entries read, and recovery passes its delete
through it, so an unreadable status lands on the status-unreadable message that
re-arms when a readable gate arrives, and only a readable refusal still walls.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 08:22:25 -04:00
Jinwoo Hong 3aefee4a13 feat(mobile): native page-shell bridge in orca-mobile-web-shell (OTA phase C, C0.2) (#21434)
* feat(mobile): native page↔shell bridge in orca-mobile-web-shell (OTA phase C, C0.2)

Adds one prop, one event and one view function to the shell view, off unless
asked for: with `bridgeEnabled` false nothing is registered on either platform,
so Phase B's behaviour is byte-identical.

iOS accepts a `WKScriptMessageHandler` message only from our own WebView, the
main frame, the `orca-mobile-web` scheme and the session we loaded under, and
replies through `callAsyncJavaScript` with the payload bound as a real JS value.
Android registers a `WebMessageListener` gated on a `WEB_MESSAGE_LISTENER`
feature query (Chromium 88; unsupported is `isolation-unavailable`, and only
when the bridge was asked for) and replies through the reply proxy.

Simulator-measured before any acceptance logic was written: WKFrameInfo's
securityOrigin does populate for the custom scheme, but WebKit ASCII-lowercases
the host, so `orca-mobile-web://sess-01JN_aZ9/` reports `sess-01jn_az9`. Exact
equality would refuse every message from a mixed-case session id. Folding is
ASCII-only rather than caseInsensitiveCompare, because U+212A KELVIN SIGN folds
to `k` under Unicode and would match a host nobody minted.

The 640 KiB cap is measured on the raw UTF-8 string. Inbound it is a silent,
counted refusal; outbound `postBridgeMessage` throws, because its only caller is
the host and a dropped reply is a request that never settles.

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

* fix(mobile): pick the completion-handler callAsyncJavaScript overload

The trailing closure resolved to the `async` overload, which the compiler read
as an extra trailing closure. The label is `in contentWorld:`, and naming the
completion handler is what selects the synchronous one. Restates the two
exception classes' inherited Sendable conformance, which Swift 6 warns on.

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

* fix(mobile): fold the request host ASCII-only, shared with the bridge

`resolveRequestPath` compared the request host with `caseInsensitiveCompare`,
which folds U+212A KELVIN SIGN to `k`, so a host nobody minted could match a
session id containing `k` and be served every asset. Both predicates now use
one `MobileWebShellOrigin.asciiLowercased`.

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

* fix(mobile): converge the shell load guard on applied props, not install success

The re-entry guard compared `bridgeEnabled` with `bridgeInstalled`, which is
written only where the install succeeds. With the prop true, every early return
— malformed session id, unreadable generation, a WebView with no
WEB_MESSAGE_LISTENER — left the two unequal, so the next prop commit re-entered,
reset the state machine and re-emitted loading then failed, forever.

Both platforms now record the prop triple and compare it field by field in one
pure `MobileWebShellAppliedProps.matches`, checked by swiftc and JUnit.

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

* fix(mobile): settle postBridgeMessage on delivery and bind it to the frame that spoke

postBridgeMessage resolved whatever happened: the completion handler was nil,
and `bridgeInstalled` stayed true after the renderer died and after a failed
prop update, so the host's request never settled. It also posted with `in: nil`,
which means the current main frame, while page to native binds to the applied
session.

Both ends now use the frame the last accepted message came from, checked
against the applied session id with the same ASCII fold, and the promise is
rejected when there is nowhere to post or when WebKit reports the delivery
failed. Android drops its reply proxy on the same three events for parity.

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

* fix(mobile): let the bridge delivery script throw when the page has no bridge

`if (bridge) { bridge.__deliver(m) }` made a page the installer never ran in
indistinguishable from a delivered message: the script completed, so
callAsyncJavaScript succeeded, so the host's promise resolved on a message
nobody received. Unguarded, the missing global throws and the promise rejects.

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

* test(mobile): pin the applied-props record to the fields it compares

Nothing failed if a fourth prop joined the record and no comparison mentioned
it — the prop would simply never reload. Both suites now assert the record's
stored fields by name, so adding one without deciding whether it re-enters is
red rather than silent.

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

* test(mobile): import assertEquals for the applied-props field pin

Belongs with the previous commit, which left the import behind; no amend.

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

* fix(mobile): refuse and unbind the document a prop update replaced

Two ways the previous document kept speaking for the load that replaced it. On
Android a failed prop update nulled `served` and the reply proxy but left the
web message listener installed, so a page still alive after `stopLoading` posted
through a listener bound to the origin this mount had stopped serving, and
re-armed the proxy doing it. Every disable path now goes through one removal.

On both platforms that document is same-origin whenever only the directory or
the bridge prop changed, so it passed acceptance between `stopLoading` and the
next commit and emitted after the host was told `loading`. Acceptance is now
armed at navigation commit — `didCommit` on iOS, `onPageStarted` on Android —
and disarmed by a new prop triple, a failure, and a renderer that died. The
state lives in the load-state machine and the arming clause is a field of the
pure accept predicate, so both are checked by swiftc and JUnit.

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

* fix(mobile): hold the bridge post target only for the document that armed it

`WKFrameInfo` outlives the frame it describes, so the held target has to be
cleared at the commit that re-opens arming as well as at the provisional start,
and a post in flight between the two has no document to go to.

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

* fix(mobile): publish the Android bridge state written off the main thread

`reportDocumentFailure` runs from `shouldInterceptRequest`, so the reply proxy
it drops and the commit flag it clears are written off the UI thread that reads
them. Same reason `documentFailed` and `served` already carry it.

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

* docs(mobile): say what a resolved postBridgeMessage does not prove

Android's reply proxy is void with no acknowledgement, so resolve there means
enqueued. The shared handle promised delivery, which is only ever an iOS answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 07:38:47 -04:00
Jinwoo Hong 8fc81182ab feat(mobile): envelope contract for the web shell bridge (OTA phase C, C0.1) (#21432)
* feat(mobile): bound a web-shell bridge frame at one enforcement point

The page and the shell exchange frames over a native channel that will happily
carry whatever either side hands it. `parseBridgeMessage` is the only place the
byte, depth and node caps are checked, and the byte cap is checked against the
raw string so it protects `JSON.parse` rather than trusting it.

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

* feat(mobile): carry a bridge rejection without losing its delivery-unknown mark

A host `RpcFailure` is data and rides in the reply untouched; a rejection of
`sendRequest` is the other path and needs rebuilding page-side. The mark that
says the request may already have run is a `WeakSet` on object identity, so it
cannot survive serialization and has to be re-applied, and the recorder reads
`error.constructor.name`, so the rebuilt error is named rather than plain.

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

* feat(mobile): declare every message the web shell bridge carries

One schema per message in both directions, with `v` gating envelope shape and
`init.grants` gating capability. Unknown keys are dropped rather than refused:
the page bundle ships from a desktop that updates independently of the installed
shell. A reply payload is read through loose objects so a field a newer host adds
reaches the page unaltered, which is what the goldens record.

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

* feat(mobile): split an oversized bridge reply instead of refusing it

The native screens have no reply byte cap, so refusing one at the frame cap would
invent a failure the phone does not have; source control's diffs would be first
to hit it. Frames are measured after serialization and only then accepted, so an
escaped control character or a surrogate pair cut across the boundary cannot push
one over. The absolute ceiling aborts the request rather than truncating a reply.

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

* test(mobile): pin the bridge numbers rather than deriving every fixture

A test that builds its fixture from the constant it is checking moves with that
constant: widening the frame cap, the depth, the node count or the reply ceiling
left every boundary case passing. These numbers are wire between a released shell
and a page served by a desktop, so they are pinned as literals; the in-flight and
subscription caps had nothing holding them at all.

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

* feat(mobile): bound the page's frames, not the desktop's answers

The depth and node caps exist to bound the cost of walking a hostile frame,
and only one direction is hostile. A 5 000-row listing reply carries 25 000
values, so holding the shell's answers to the same 20 000 node cap would
refuse ordinary data. `parseBridgeMessage` now takes the direction and walks
`page-to-shell` only; both directions keep the frame byte cap, and a chunked
reply keeps the 8 MiB ceiling as its single bound.

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

* fix(mobile): carry a decoded screencast frame, not just its bytes

The binary `event` carried `b64` alone, but a binary listener is handed an
already-decoded `BrowserScreencastFrame`: format, metadata and the
screencast's own frame counter would all have been lost, and the envelope's
`seq` is the backpressure counter, not that one. The frame's fields now ride
beside the base64, mirrored field for field, with a compile-time pin that
nothing but the image is missing. C6 writes the encoder.

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

* test(mobile): fail to compile when the sender grows an option

The options pin only proved the schema accepts what the sender declares
today. A `Record<keyof SendRequestOptions, true>` makes the other direction
a compile error, so a new option cannot ship past the bridge unnoticed.

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

* fix(mobile): carry an error code of any shape, as the recorder does

The capture narrowed `code` to a string or a number, but the recorder
records whatever code it finds. A structured code would have crossed the
bridge as an absent field and moved a golden the day C0.5 replays through
it. Absent still means absent.

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

* refactor(mobile): let the schema state the part bound on its own

The splitter's parts-ceiling branch could not fire: the largest reply the
ceiling admits, with every character re-escaping, splits into 26 parts
against a cap of 27. A branch no input reaches is a second statement of a
bound that drifts from the first. The derivation is pinned by a test now,
and `replyPartSchema` is the only place the bound is written.

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

* fix(mobile): bound the ids a reply assembler holds at once

Nothing expired a half-assembled reply, so a host that sent a first part
and never a last one grew the map for the life of the page. A reply exists
only for a request the page made, so the in-flight cap is the right bound,
and the new id is the one refused. C0.4 owes the assembler a discard for
every request it settles.

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

* test(mobile): refuse a screencast metadata field that is not a number

Only the compile-time pin stood between a metadata field and `z.unknown()`.
Every one of the nine is now exercised, so widening any of them fails a
test rather than only a typecheck.

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

* fix(mobile): survive an error whose own getter throws

Reading `code` and `cause` runs whatever getter defined them, and both were
read in one parse, so a getter that throws took the capture with it: the
rejection path would have thrown where it had to produce an envelope. Each
field is read on its own now, and a throwing getter costs that field only.

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

* fix(mobile): keep a surrogate pair whole across a chunk boundary

A pair cut in half encodes as two replacements, three bytes each, where the
pair whole is four. The sender cut by code unit and the assembler summed the
parts, so a reply within two bytes per boundary of the ceiling was refused
`reply-too-large` for bytes it never had, and each half-pair frame was not
well-formed UTF-8 for the native bridge to carry. The cut backs up one unit,
and the ceiling is measured once on the joined text. Code units still bound
what is held, since a reply is never fewer bytes than code units.

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

* fix(mobile): always produce a capture, whatever the error does when read

`message` and `constructor` can be getters, and `String(value)` runs a
`toString` the thrower wrote, so reading an error is running someone else's
code. A throw there left the rejection with no frame at all and a promise
that never settles. The whole capture is guarded now, and the fallback still
carries the delivery-unknown mark, which is a `WeakSet` lookup.

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

* fix(mobile): make an error frame sendable by construction

A megabyte message or code is not a protocol error, it is a big string, and
it produced a frame the receiver refuses as oversized: a rejection the page
never hears. A cyclic code took JSON.stringify down with the whole frame.
Messages are truncated to 16 KiB and marked, a code is dropped when it will
not serialize or is past 4 KiB, and the worst chain the budgets allow now
measures 512 KiB against the 640 KiB frame cap.

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

* fix(mobile): keep a refused reply refused, and bound them together

Every failure dropped the id, so the next part opened a fresh accumulator:
a duplicate part then a whole set completed, and one id could feed 67 MB
through an 8 MiB ceiling one refusal at a time. A refusal is remembered now
and answers every later part, until the page discards the id. The bytes held
across all ids gain a ceiling of their own, since 64 replies at the per-reply
ceiling is half a gigabyte of parts that never complete.

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

* docs(mobile): say that a new enum member is not an additive field

The version rule read as though anything additive was safe. A value outside
a closed list is refused whole by the older side, so `end.reason`,
`binary.format`, `connection.state` and the foreground reasons are
negotiated, not appended. The byte-cap comment had its inequality the wrong
way round while I was there.

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

* test(mobile): pin what the guards keep, not only what they drop

Two mutants lived: clearing the assembler could have kept its tombstones,
and the guard around a cyclic code was hidden by the outer guard added for
a throwing getter. The capture is now asserted whole, so dropping the code
has to leave the message and the cause behind, and teardown has to forget.

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

* fix(mobile): carry the code that was measured, not the one that made it

A stateful `toJSON` answers the budget check and the frame serializer
differently, so the snapshot is what crosses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 07:21:31 -04:00
Neil 01beadbcf0 fix(explorer): make filename search find all workspace files (#21423)
* fix(explorer): search file names through runtime

* fix(explorer): keep filename search results complete

* fix(explorer): narrow runtime search change

* test(explorer): remove unsupported local search assertion

* fix(explorer): fence filename search results
2026-09-18 04:05:23 -07:00
Neil 7909dad7ba fix(ci): keep mobile patches LF so Windows can parse them (#21439)
A Windows checkout CRLF-converted mobile/patches/*.patch because no
gitattributes rule covered them, and pnpm rejected the result with
ERR_PNPM_INVALID_PATCH, failing package (windows) and the verify
aggregate. config/patches/*.patch has been pinned -text for this exact
reason; mobile/patches/ was added later and never got the same rule.

git ls-files --eol showed all three mobile patches with an empty attr
against attr/-text on every config patch.
2026-09-18 03:42:48 -07:00
Neil 8f9a55ef8a fix(editor): restore editability after View Log (#21424) 2026-09-18 03:36:15 -07:00
Neil 89ee2e7363 fix(editor): preserve markdown on clipboard copy 2026-09-18 03:27:42 -07:00
Neil f7b3a4a3fb test(editor): cover bounded table output 2026-09-18 03:22:51 -07:00
Neil 3ad7b36afb fix(editor): bound markdown table column padding 2026-09-18 03:21:21 -07:00
Jinwoo Hong ca2ae89011 ci: install mobile dependencies in every desktop packaging job, pin mobile page source to LF (OTA phase C, C0.6) (#21425)
* chore(mobile): pin mobile page source to LF so a Windows checkout keeps buildId

The Phase C web bundle hashes every text byte under mobile/src and mobile/app into
its asset digests and from there into buildId. There is no global text=auto, so a
CRLF checkout on Windows would give the Windows release a different buildId for
identical source, the same failure the src/mobile-web pin above exists for.

All 1924 tracked files in those directories are already LF in the index, so the
pin renormalises nothing. mobile/web-entry does not exist yet; the pin is
forward-looking for the Phase C entry point.

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

* ci: install mobile dependencies in every desktop packaging job

Ten workflows reach build:release/build:desktop and none of them installs
mobile/node_modules. Root has no react-native, react-native-web or expo, so once
the mobile web bundle builds from mobile/ its packaging jobs would fail at
electron-builder's beforePack with an unresolvable import.

Extract the frozen mobile install that pr.yml's static analysis job already ran
inline into .github/actions/install-mobile-dependencies, and invoke it from every
job the packaging census enumerates, after the root install and before the build.
Same --frozen-lockfile, same lockfile-drift guard, and still no --ignore-scripts:
mobile's postinstall generates the gitignored webview engine modules that tracked
source imports. pr.yml now uses the action too, so there is one definition.

Where a packaging job's setup-node caches the pnpm store, mobile/pnpm-lock.yaml
joins cache-dependency-path so a mobile lockfile change invalidates it. Two jobs
(daemon-relocation-spike, win-update-survival-e2e) do not cache at all and are
left alone.

The census test grows a per-job assertion that the action is present, so a new
packaging job has to add the install deliberately rather than discover it at
beforePack. release-cut's composite-action restore is no longer Windows-only:
every platform consumes this action now, so any of them can be the leg whose cut
ref predates it.

No job builds anything different; this only makes mobile/node_modules present.

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

* test(ci): assert the mobile install contract on the shared action

pr.yml's static analysis job no longer carries the install inline, so the scope
test's findIndex by step name resolved to -1. Match the step by the action it
uses, and read working-directory and --frozen-lockfile off the action itself so
the job cannot keep the step while the action stops installing anything.

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

* chore(mobile): exempt binary asset types from the mobile LF pin

/mobile/{src,app,web-entry}/** text eol=lf would mark a future PNG or font as
text and rewrite its bytes on a Windows checkout. Exempt the asset types an RN
page carries, the same way src/mobile-web exempts its PNG.

-text after text eol=lf wins: probed a CRLF-bearing .png under the pin, it stays
i/crlf attr/-text while a sibling .ts still normalises to i/lf. No tracked file
changes classification; the 1924 files under mobile/src and mobile/app stay i/lf.

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

* ci: gate the mobile install with the build it feeds in the cached lanes

win-crash-survival, win-update-survival and daemon-relocation-spike all skip
electron-builder on an installer/unpacked cache hit, so an unconditional mobile
install spent time on node_modules nothing then consumed. Move each `uses:`
below its cache step and carry the same cache-hit condition as the build.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 05:44:11 -04:00
Neil 95b121cac2 fix(editor): handle tilde fences in ordered items 2026-09-18 02:40:51 -07:00
Neil bf8c14df78 fix(editor): stabilize fenced code in ordered lists 2026-09-18 02:36:00 -07:00
593141590e fix(terminal): retire captured remote handles when pending panes close (#21005)
* fix(terminal): retire captured remote handles when pending panes close

A restored pane can hold a scoped `remote:<environment>@@<handle>` layout
binding while `remote.attach()` is still waiting for `terminal.resolvePane`.
The transport's `getPtyId()` is null, so an explicit split close passed null to
`closeWebRuntimeTerminal`, dropped the binding and destroyed only the viewer.
The host terminal stayed connected.

Only an exact scoped handle whose environment matches the owning workspace's
runtime authorizes the close. The provider helper captures the pairing
revision, runs its existing compatibility check, then rechecks pairing and
ownership immediately before dispatch.

Rebased onto main after #21001 was squash-merged. The previous head was a merge
commit that carried its own conflict-resolution content -- the runtime branch in
`terminal-pane-close-admission.ts` and the restored `it.each([false, true])`
parameter -- which a plain rebase drops along with the merge. Rebuilt from the
recorded net diff instead and verified byte-identical at 15 files,
906 insertions, 41 deletions.

* test(memory): rebase the pending runtime-close proof onto the squashed base

`fix.patch` recorded a baseline taken against #21001's pre-squash branch tip.
Squash-merging #21001 replaced that tip with a single commit, so the recorded
hunks no longer reverse-applied and `reproduce.mjs` aborted with
`Source changed: use-terminal-pane-close-actions.ts` -- confirmed by running it
before regenerating rather than assuming the rebase alone would fix it.

Regenerated against `main` and re-run: 5 pass / 10 fail before, 15 pass / 0
fail after, exit 0, and every `results.json` hash recomputed from the run
rather than hand-edited.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 02:13:11 -07:00
Neil 46d7ecf4d1 test(runtime): pin the merged-predecessor lockout the receipt ledger allowed (#20725)
The production change this branch carried - asking the receipt ledger the
same lineage-aware "is it retired" question as the recovery gate - landed
in the base branch (#19860) as part of "give 'same publisher' one answer
across the epoch fences". Rebasing onto that base leaves the regression
case, which is the part the base does not have.

`fences a merged predecessor at the recovery gate as well` stops one frame
early: it asserts the merged frame loses and never asks whether the live
successor still gets in afterwards. This case asks, for both the bare and
the merged shape. The bare shape passes without the ledger fix and is the
control.

Mutation: restoring `history?.retired.includes(publicationEpoch)` in
`recordReceivedWebSessionTabsSnapshot` fails only the merged-shape case.
2026-09-18 02:13:03 -07:00
Neil ce8f93cb56 fix(editor): preserve zero-start ordered lists 2026-09-18 02:04:20 -07:00
Jinwoo Hong b749091b67 feat(mobile): native shell view serving a mobile web generation from a private origin (OTA phase B, 3/4) (#21417)
* feat(mobile): declare the orca-mobile-web-shell TS surface

Two props and one event: a generation directory the TypeScript store owns, a
session id that scopes the private origin, and a load state. No module
functions and no reload — a retry is a remount under a new React key, which
rebuilds the WebView and reinstalls every fence.

The native event body is a flat dictionary, so parseMobileWebShellLoadState
rebuilds the union instead of asserting it and answers null for anything it
does not recognise.

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

* feat(mobile): serve a generation from a private origin on iOS

A WKWebView behind a custom-scheme handler that answers only from a map built
once from the generation's manifest, with the CSP as a response header on the
document. The scheme handler reads on a serial background queue and keeps a
live-task set that stop() removes from: an asset is up to 10 MiB, and
delivering to a stopped task raises an Objective-C exception Swift cannot
catch.

Origin, request refusal, the manifest map and the policy header hold no WebKit
type, so tests/MobileWebShellChecks.swift compiles and runs them with swiftc,
no device.

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

* refactor(mobile): compare the iOS shell's applied props field by field

One joined string could not tell a directory ending in the separator from a
shorter one with a longer session id. Two fields have no separator to collide
on.

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

* test(mobile): pin that a string schemaVersion is not a manifest

The contract declares a number. The Kotlin side read it with optInt, which
coerces "1" to 1, so a manifest that widened the field would have been served;
this check covers the same shape on both platforms.

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

* feat(mobile): serve a generation from a private origin on Android

A WebView behind shouldInterceptRequest, answering only from the same
manifest-built map as iOS, with the CSP as a response header on the document.
The origin host label is a slice of the session id's SHA-256, never of the
session id: Chromium lowercases an https host and java.net.URI reads null for
a label holding '_', which is how the reference 403'd every asset.

A main-frame failure is reported from a post() because Chromium commits its
own error document after onReceivedError returns. onRenderProcessGone destroys
the dead WebView and does not rebuild it, so the retry policy stays in one
place. clearCache(true) is never called: it is process-global and would wipe
the terminal WebView's cache too.

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

* chore(mobile): untrack the shell module's gradle build output

The previous commit staged 312 files from android/build. mobile/.gitignore
anchors /android/ at the mobile root, so a module's own gradle output was
never covered.

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

* refactor(mobile): parse the shell load-state payload with a zod shape

The anti-slop gate rejects an `object` parameter and `Reflect.get`. zod reads
a shape key straight off the value, so the own-property strip stays.

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

* feat(mobile): give the web shell one load-state machine per platform

A failure is terminal, and a repeat says nothing. Chromium commits its error
document after onReceivedError returns and a rule list compiles long after a
generation was refused, so both platforms could report over a failure the
caller had already acted on.

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

* fix(mobile): stop the Android shell reporting ready over a failed document

onPageFinished ran after reportDocumentFailure's post and both emitted `ready`
and set the WebView visible again, putting Chromium's error page on screen. A
prop change after the renderer died now reports instead of going silent.

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

* fix(mobile): publish the Android shell's served generation atomically

The map and the host it is keyed against were two plain fields written on the
main thread and read on Chromium's, so an interceptor could see a stale null
and 403 a good frame, or a new map against the previous host.

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

* fix(mobile): keep the iOS shell to one terminal load state

A rule list that failed to compile after a generation was already refused
emitted a second, contradictory reason. The document-failure flag it carried
is now the state machine's.

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

* fix(mobile): stop serving the previous generation after a failed prop update

Both platforms returned early with the old map still installed and the old page
still on screen, so a caller told the shell had failed was looking at a working
one from the generation before.

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

* fix(mobile): serve the shell document at "/" and nowhere else

/index.html answered the same bytes without the policy header, which rides the
document response alone.

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

* test(mobile): pin the shell's response headers as a pure predicate

Which response carries the policy header was decided inside the two request
handlers, where no test without a device can reach it.

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

* test(mobile): pin the shell's path-length edge and its charset casing

Both limits were checked only from the rejecting side, so a one-off length and
an uppercase charset passed unnoticed.

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

* chore(mobile): state the Android shell's file-URL settings and what B4 must check

The two file-URL settings were left to their defaults, and the settings that
only a device can prove named nobody to prove them.

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

* chore(mobile): drop the shell module's unresolved entry points

Nothing imports the module by name, on either side; the TypeScript is reached
by path, as the notification-dismissal module's is.

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

* fix(mobile): stop the iOS shell failing a document it cancelled itself

stopLoading on a prop update and every navigation the policy delegate refuses
reach the failure delegates as errors, so a healthy page reported `failed`,
lost its `ready`, and sent the caller to delete a good cached generation.

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

* fix(mobile): answer when the iOS rule list store is missing

Optional-chaining past a nil store ran no completion handler, so the view
stayed at `loading` for good. The next prop update now reads the same terminal
isolation failure a compile failure sets.

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

* fix(mobile): refuse a manifest whose schemaVersion is true or 1.0 on iOS

NSNumber bridges both to 1, so `as? Int` accepted a manifest Kotlin rejects.
Verified against JSONSerialization: objCType is c for true, d for 1.0, q for 1.

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

* fix(mobile): drop an Android document failure the next load did not have

The report is deferred past Chromium's error document, so a prop update could
land between the decision and the report and fail the generation that had just
replaced the one that actually failed.

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

* test(mobile): assert each blocked global's descriptor whole

contains("writable:false") passed on a WebSocket descriptor that had lost it,
because the serviceWorker copy still carried one.

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

* test(mobile): pin the Android shell's navigation and refusal decisions

Both lived inside the WebViewClient, which no suite compiles, so dropping the
navigation guard or answering a refusal with 200 changed nothing anyone could
see.

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

* fix(mobile): name the domain a policy-cancelled frame load is reported under

WKErrorDomain has no frame-load codes: WKErrorCode stops at the app-bound
domain errors, and 102 belongs to the legacy WebKitErrorDomain. The iOS SDK
exports no symbol for it, so the assert that pinned one is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 05:03:30 -04:00
Jinwoo Hong db2ffe7afe fix(mobile): default injected timers to receiver-free wrappers (#21416)
* fix(mobile): default injected timers to receiver-free wrappers

Every transport class stored a global timer function on an object and then
called it back through that object, so the receiver was the instance or the
dependency bag rather than the global. Hermes ignores the receiver; browsers
reject it with TypeError: Illegal invocation, which makes the web build fatal
at the first retry, liveness probe, or relay grace timer.

Default each injected timer to a wrapper that calls the global receiver-free,
and narrow the seam's type from `typeof setTimeout` to the call signature it
actually uses. Node's `typeof setTimeout` also demands a `__promisify__`
member that no injected timer or wrapper can supply, so the wrapper cannot
satisfy it. Pruning mobile-relay-background-grace.test.ts from the typecheck
baseline follows: the narrower type makes that file check clean.

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

* test(mobile): pin the default timers against a browser receiver check

Both classes are now constructed with no injected timers under a global
setTimeout/clearTimeout that throws Illegal invocation for any explicit
non-global receiver, mirroring the WebIDL rule. The watchdog gets its own
file because its existing test is grandfathered out of the typecheck ratchet.

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

* test(mobile): prove the default clear leg and drop bare timer injections

The clear assertions were vacuous: cancel() and stop() also drop the state a
fired callback checks, so a no-op default clearTimer stayed green. Both tests
now assert the wrapped global clearTimeout received the exact handle setTimeout
returned, which fails when that default is mutated to a no-op.

Three relay tests injected bare setTimeout/clearTimeout into dependency bags,
the same receiver shape the product fix removed; inert under node, fatal under
jsdom. relay-host-signed-out-verdict drops two `as unknown as typeof setTimeout`
casts, since ScheduleTimer now types those arrows contextually.

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

* test(mobile): census bare global timers parked in properties and defaults

mobile-endpoint-lifecycle could regress to bare globals with every other test
green, because nothing there is reachable from a unit test. Walk every product
file's AST and fail on a global timer parked where a later call reaches it
through a receiver: a `??` or `||` default, an object literal member, or an
assignment onto a property. A plain local capture stays legal, since calling it
bare leaves the receiver undefined.

A separate test asserts the walk sees the five fixed sites' wrapper shape, so an
empty or misdirected scan fails instead of passing vacuously.

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

* refactor(mobile): define the receiver-free timer defaults once

Five hand-written wrappers each restated the same invariant, so five places
could drift. timer-scheduler now exports defaultScheduleTimer and
defaultCancelTimer, and carries the reason for them; every site takes its
default from there. The census keys its presence precondition on those two
identifiers instead of the arrow shape.

The census also missed `??=` and `||=`, which park a global exactly like their
non-assigning forms. Both are handled now, with a parsed-source case per parking
form and one for the local capture that stays legal.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 04:59:56 -04:00
Neil 5c2d3322c1 fix(runtime): name a terminal whose pane a graph republish dropped (#19860)
* fix(runtime): name a terminal whose pane the graph dropped

`buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement
with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose
`paneKey` still parses to its own `tabId` passes that forever, including long
after the session graph dropped the pane, so a terminal that had lost its
surface reported `orphaned: false, connected: true, writable: true` and a
`tabId` no tab has: field-for-field identical to a healthy one (#18191).

Consult the leaf topology instead, gated on a graph statement having had the
standing to contradict the record. `graphSequence` counts authoritative graph
statements; every statement re-records the surface of every pane it publishes,
so a pane the current graph holds carries the current stamp and is answered
without touching the leaf map. That covers the two absences that are not
evidence, without a second flag: a surface recorded since the last statement
(spawn records the pane before the graph carrying it arrives, #7587), and a
lost graph clearing every leaf at once without advancing the sequence. A pane
already observed dropped keeps its stale stamp and stays named, because losing
the ability to re-check is not a reason to un-see it.

`orphaned: true` is shipped vocabulary that both consumers already read, so no
capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and
now reaches this population, and the duplicate-surface index
(`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's
live owner.

* fix(runtime): publish a terminal retirement proof on the exit's own evidence

A paired client may drop a mirrored terminal on exactly two kinds of host
evidence: a `retiredTerminalSurfaces` proof naming the handle, or two
authoritative `terminal.list` inventories that omit it. The second needs two
host publications, and a quiet workspace publishes one, so the proof is the
only evidence that rides the frame carrying the retraction.

That proof was minted only as a byproduct of persistence *accepting a change*,
which made one value carry two meanings: "a change was accepted" and "the PTY
exited". The host renderer's close transaction de-persists the surface and
republishes without it, so when it got there first the exit found nothing left
to accept and the attestation died with it. Measured on a real paired client:
the host retracted in under 500ms, published no proof, then froze its
snapshotVersion for 60s while the client kept a dead pane in its tab bar.

Persistence still gates *removal* — publishing absence before the membership
fence is durable would let a crash resurrect the surface. It no longer gates
the proof: the observed exit is itself the attestation.

The exit-first ordering already had a passing test; the renderer-first ordering
had none, and that is the one users hit. Both orderings are now pinned, with
exit-first as the control that makes the renderer-first failures mean something.

Wire: `retiredTerminalSurfaces` is an existing optional field on an existing
path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is
Rule 1 — an old client that ignores it degrades to the two-inventory route it
already uses today, so no capability gate is needed. The sentence "the host
starts sending a frame it did not send before" reads like Rule 3; it is not,
because the frame shape, the field, and the reader contract are all unchanged.

* test(runtime): pin the removal frame retiring a still-live publisher

KNOWN RED (`it.fails`), no product change. Found while verifying the close
retraction fix: once the emptying actually reaches paired clients — a state the
previous behaviour never allowed, because nothing propagated — re-adoption of a
later create is flaky. Measured 1 failure in 6 runs of the two-client journey.

`decideWebSessionTabsSnapshot` treats the host's synthetic `removed:<t>`
retraction as a publisher handover: it retires the still-live renderer epoch and
installs the retraction as current, while the removal also clears the live
freshness record. The next frame from that same running publisher then matches
no lineage and reads as a retired generation, so it is outranked and the
publisher is locked out of the worktree until its generation changes.
`local-structured-session-tabs-sync/snapshot-apply.ts` documents this exact
scenario and has a revive escape; the mirror path has none.

The suffix case explains the 1-in-6: `hasRetiredValue` is an exact string match,
so a republication carrying `:headless-merge:` walks past the fence and only a
bare same-epoch republication is locked out.

Not fixed here on purpose. Dropping the retirement makes the red case pass but
breaks `web-session-tabs-sync.test.ts > keeps a removed worktree fenced against
delayed predecessor epochs`, which asserts a same-epoch higher-version frame
after a removal must be rejected. At this layer those are the same frame — this
function holds no `receivedFrame`, so it cannot separate a delayed predecessor
from the live publisher speaking again. The fix belongs in
`shouldApplyRecoveredWebSessionTabsSnapshot`, which does hold that ordering and
currently defers to the same epoch fence. That is a contract change across two
functions and an existing invariant, not a one-liner.

* fix(runtime): a removal retraction is not a publisher handover

The host drops a worktree's entry when its last tab closes and announces it
with a synthetic `removed:<t>` epoch. Both receipt sites treated that as a
publication: `decideWebSessionTabsSnapshot` and
`recordReceivedWebSessionTabsSnapshot` each noted the retraction epoch as
current, which pushed the still-live renderer epoch onto `retired`. The removal
also drops the live freshness record, so the next frame from that same running
publisher matched no lineage, read as a retired generation, and was outranked.
The live publisher was locked out of its own worktree until its generation
changed. That is fail-closed, and it is why re-adoption after an emptying was
flaky once the emptying actually reached paired clients.

A retraction and the live publisher's next frame are the same epoch at a higher
version, so epoch identity cannot separate them and never could. Delivery order
can. `recordReceivedWebSessionTabsRemoval` now records the retraction as the
worktree's newest received evidence instead of deleting the ledger, so
`shouldApplyRecoveredWebSessionTabsSnapshot` — the gate every production apply
path passes before `decideWebSessionTabsSnapshot` — fences a frame that
reserved its received frame before the retraction while admitting one that
arrives after it. The boundary carries the retraction's own epoch, which never
matches a host publication, so a later live frame may still restart its version
counter. `local-structured-session-tabs-sync/snapshot-apply.ts` documents the
same conclusion for the local path: a retired epoch is not proof of a dead
generation.

`keeps a removed worktree fenced against delayed predecessor epochs` pinned the
delayed predecessor at the raw decision layer, which is the same call as the
live publisher's republication. It now pins the identical scenario — same
epoch, higher version, still rejected — through the receive-and-apply path that
actually holds the ordering, plus the composed gate as production spells it.

The committed `it.fails` repro is not sufficient on its own: it records no
received frame, so dropping only the `decideWebSessionTabsSnapshot` retirement
turns it green while the publisher stays locked out on every real path. A
receive-and-apply case is added alongside it to close that gap.

* test(runtime): pin the retraction boundary against a stale inventory omission

Mutation testing left a survivor: writing the boundary unconditionally, instead
of only when it advances the ledger, passed the whole runtime suite. It is not
inert. A visibility-resume inventory reserves its received frame before it
lists, so an omission it reports can be older than a stream frame that landed
meanwhile; without the guard that stale omission rewinds the ledger, forgetting
the stream frame's version, and a delayed list reserved in between is then
readmitted instead of outranked. This pins that ordering.

The one remaining survivor is the boundary's `snapshotVersion`, and it is inert:
the ledger's version is read at exactly two sites, both reachable only when the
incoming frame's epoch equals the stored one, and a retraction epoch never
equals a live publication.

* test(runtime): cover the fences the retraction change narrowed

Two gaps found by mutating the fences themselves rather than the fix.

Deleting the epoch fence in `shouldApplyRecoveredWebSessionTabsSnapshot` passed
the entire runtime suite. It is not unreachable: a superseded generation whose
sibling stream delivers its frame after the handover outranks the successor on
delivery order, and only the retired-epoch check rejects it. Retractions used to
exercise that fence too; now that they no longer retire anything, a genuine
handover is the only thing left that reaches it, and nothing covered that. The
fence is narrower than it was, not dead.

The second case pins rate-independence. The defect surfaced 1 run in 6 because
`hasRetiredValue` is an exact string match while `sameSessionTabsPublicationLineage`
treats `:headless-merge:` as the same publisher, so a merged republication walked
past a fence a bare one hit. The removal path is now asserted over both epoch
shapes through the full path, so a fix that only re-rated the defect instead of
removing it would fail here.

* fix(runtime): give "same publisher" one answer across the epoch fences

Separable from the retraction fix beneath it, and it changes handover-path
behaviour: a superseded generation that republishes under a merged epoch is now
rejected where it was previously accepted. Take it independently or not at all.

`publisher-identity-fences.ts` held two answers to "is this the same publisher".
`noteRetiredValue` treated a `:headless-merge:` epoch as a SUCCESSOR of its base
and retired the base when the merged form became current, while
`sameSessionTabsPublicationLineage` treated the two as ONE publisher. Those are
contradictory, and the retired-value check's exact-string match was the shim
that kept them from ever meeting: a merged frame was a different string, so it
never looked retired no matter what had been retired.

The cost was that the same predecessor was accepted or rejected depending on
which shape it arrived in. A generation a successor had replaced was fenced when
it republished bare and admitted when it republished merged — the fail-open half
of the same disagreement whose fail-closed half was the removal defect, and the
reason that defect reproduced 1 run in 6 rather than every time.

This cannot be fixed in the fence alone. Making the fence lineage-aware while a
merged epoch still retires its base has the generation retire itself: the
rebuild arrives, retires its own base, and the fence then rejects it as a
retired generation. So both sides move together — a lineage sibling advances the
current epoch instead of superseding it, and inherits its generation's
retirement instead of escaping it.

Scoped to the publication-epoch functions. Runtime-id retirement keeps exact
matching, and `local-structured-session-tabs-sync` keeps its own
`hasRetiredValue` call, where a lineage sibling is already excused explicitly
and a retired epoch is deliberately not treated as proof of a dead generation.

* test(e2e): journeys for a reopened client and two clients on one host

Two gaps this suite had no coverage for, both driven end to end against a real
paired desktop client rather than at a seam.

A relaunched client holding a live remote terminal: every paired restart spec
here restarts around a browser pane, none around the terminal the user is
actually mid-work in. The host-side fixture's on-disk sink is the oracle — one
READY for the whole run proves the host never re-spawned the session, and a
recorded line for input sent after the relaunch proves the restored pane is
wired to that same process rather than painted with its scrollback.

Two clients on one host across an emptied workspace: the tombstone is
client-local on the runtime path, so a client that never held a row still seeds
into a workspace another client deliberately emptied. That asymmetry is by
design; a client falling out of step with the host and staying there is not.
Phase 0 is the control — without it a later divergence cannot be attributed to
the emptying rather than to mirroring never having worked.

The input probe goes through `pane.terminal.input`, not `window.api.pty.write`:
a mirrored pane's handle is a `remote:` id that no local PTY answers to, so a
direct write is swallowed and the assertion passes on nothing. The pre-restart
control exists to catch exactly that, and did.

* test(e2e): keep the two-client journey spec type-clean

* test(e2e): pin the close retraction a paired host does not publish

* docs(e2e): say why the red close-retraction spec sits on this PR

The spec was written on a branch carrying neither of this PR's publish-side
fixes, and its own diagnosis -- the fault is the host's publish-after-close,
not any client's mirror -- names exactly what they change. Landing it here
makes CI the measurement rather than leaving a red spec parked on a branch
with no fix in it.

Records the one thing a reader needs to not do: skip-tagging it. And why the
obvious split is not a block move -- phase 2 depends on phase 1b's emptying
and both share the two-client pairing fixture, so splitting means duplicating
the fixture.

* test(e2e): the close-retraction spec is green on this branch, measured

It was written to pin a defect and was red where it was written. On this
branch, with `publish a terminal retirement proof on the exit's own evidence`
and `a removal retraction is not a publisher handover` both present, it passes
-- twice, independently: phase1a A=9ms/B=158ms then A=2ms/B=1ms, against a
prior baseline of "none reached either client within 90 seconds".

So the KNOWN RED header had become the thing it warned about: a test carrying
prose asserting the very behaviour the commits beside it remove. Rewritten to
record the measurement and the numbers to regress against, and to keep the one
instruction that still applies -- if it reddens again, do not skip-tag it; the
failure shape is a 90s timeout on both clients at once while creates still
propagate.

No assertion changed. Comment only.

* test(wire): pair the session-tabs retirement proof across two builds

The stack makes a host start sending a retirement proof on its own frame
when no surface removal carries one. The change argues Rule 1; Rule 3's
fourth bullet covers a frame the host starts sending on an existing path,
so the claim is measured against v1.4.199 rather than accepted.

Neither existing cross-version suite reaches session-tabs: the terminal
one covers the binary stream, the agent-session one covers agentSession.*.

Result: the old client acts on the proof-only frame, because the whole
client half of this surface is unchanged. The old-host cells are pinned
to a release that cannot publish the frame at all, which is what makes
the new-host cells mean something.

* fix(lint): clear the casting gate on the surface-lost inventory

main tightened typescript/consistent-type-assertions to assertionStyle:
never, which the rebase brings onto these added lines. The retraction
read narrows on the property instead of casting; the fixture and
cross-build-import casts carry per-site SAFETY rationales.

* fix(lint): bind the protected-stamp cast to a name

The leading-semicolon parenthesised call put the suppression on a line
oxfmt then reflowed away from the assertion it covers. Naming the
narrowed handle keeps the directive next to the cast.

* fix(runtime): route every non-null surface write through the stamped writer

`ptyHoldsRecordedSurface` trusts a record only while its stamp is current;
after that the leaf map answers. Four writers still named a pane with a bare
`tabId = / paneKey =` — orphan adoption (both branches), split, create on an
adopted stable pane, and TUI-owner recovery — so a record that had already
been contradicted stayed contradicted after the claim, and `terminal list`
reported the just-claimed PTY `orphaned: true` until the renderer's next
graph statement re-recorded it. Before this branch those sites read as
attached at once, so this was a regression window of one round-trip, and
`indexLiveTerminalSurfaceOwners` reads `orphaned` as "unowned".

`recordPtySurface` is now the one writer; the adoption module reaches it
through a port because it has no `graphSequence` of its own. The nulling
writers are untouched: a null surface is never held, stamped or not.

* test(runtime): keep one copy of each publisher-fence case

The removed-frame suite asserted four properties that another case in the
same suite or the lineage suite already pinned:

- the decide-only readmit and the bare full-path readmit are the bare arm of
  the parameterized full-path readmit, verbatim;
- the merged-suffix decide-only readmit is the merged arm of the same loop;
- "still fences a predecessor a successor replaced" is the lineage suite's
  bare arm with different version numbers;
- the recovery-gate handover case is the lineage suite's recovery-gate case
  with a bare late frame instead of a merged one, so that test now runs both
  shapes and this copy goes.

Mutation-checked: reverting each of the five renderer changes on this branch
(retire-on-removal in decide, noting a retraction current, the exact-match
retired fence, merge-supersedes-base, dropping the ledger on removal) still
fails at least one of the remaining ten cases.

Also corrects the suite header: a retraction carries a synthetic `removed:`
epoch, so it is the in-flight predecessor frame, not the retraction, that
shares the live publisher's epoch and needs delivery order to be separated.

* test(e2e): fail the two-client journey when phase 1a cannot run

Phase 1a sat inside `if (beforePartialClose.length > 1)`. A host workspace
that starts with one terminal skipped the control silently while 1b and 2
still ran, and the spec passed green without ever exercising the
close-with-others-open retraction it was written to measure. The skip is now
a recorded failure naming the host count.

* fix(runtime): order every session-tabs apply path against the retraction

A closed terminal came back on the other client because "this worktree was
retracted" was neither durable nor universal:

- `refreshWebRuntimeSessionTabsSnapshot` reached `decide` with no place in
  receipt order at all, so a list the host answered before the close applied
  after the retraction had already cleared the worktree. It is a production
  path for close, create, activation, split and PTY reconnect.
- the boundary lived in a single receipt slot the next stream frame overwrote,
  and in a fence that only existed when a recovery happened to be pending when
  the retraction landed, so a pre-close list could out-rank the republication
  on `snapshotVersion` alone.

Replace both with one raise-only removal watermark per (environment, worktree)
and give the list path a receipt position, reserved by the request and carried
in its answer so a dedupe joiner inherits it rather than minting a newer one.
The pending-recovery fence and its bookkeeping are dead once the boundary is
monotonic. The exact-match retirement check in the receipt ledger becomes the
one lineage-aware predicate, so a `:headless-merge:` rebuild can no longer be
noted as current and retire the live publisher out of its own worktree.

On the main side, `recordPtyWorktree` stamped `surfaceRecordedAtGraphSequence`
at write time, so any `paneKey` write claimed the standing of a fresh graph
statement. The inventory restore in `terminal list` therefore un-dropped the
very pane the read was meant to report, on every listing. A surface claim now
carries no graph standing unless its writer names one: the graph statement,
live leaf output and spawn do, while the inventory restore, the floating
liveness restore and the mobile projection replay do not. Defaulting this way
means a writer that says nothing fails safe and self-corrects, which the type
alone could not guarantee across the projection contract's own `recordPty`.

Spawn claims now span the one graph statement the renderer may already have in
flight, and retirement proofs compare by identity instead of by position, so a
re-delivered exit no longer fans out a `snapshotVersion` bump carrying nothing.

* fix(runtime): stop an unpublished-worktree placeholder retiring the live publisher

A worktree the host has published nothing for still answers a forced list, with
a synthesized `none`/v0 frame that means "ask me later"
(host-session-snapshot-authority.ts). Every post-close list and every
activation of an emptied worktree gets one. Noting it as a publication retired
the renderer generation that is still live, and because that epoch is
per-process, the terminal the user created next never reached this client — the
same lockout the retraction path was already careful to avoid, through a door
it did not cover. `local-structured-session-tabs-sync` already skips the
placeholder for this exact reason; the web mirror now does too, on both the
receipt ledger and the frame decision.

Bound the receipt ledgers by frame age rather than entry count. One bootstrap
inventory records a receipt per worktree under a single reserved frame, so
evicting by insertion order dropped that batch's own earlier entries, and an
absent receipt is what the recovery gate reads as "no evidence for this
worktree". Only a receipt no in-flight frame can still be ranked against is
droppable.

Take the receipt gate off the `web-session-tabs-sync` barrel in the refresh
path. Ordering is that path's gate, not an optional collaborator a caller's
module mock may leave out, and being reachable only through the barrel is how
the path came to have no ordering at all.

* fix(runtime): let the TUI-owner recovery name its pane without claiming the graph holds it

`recoverStructuredTuiOwner` rebinds a recovered PTY from the persisted owner
binding — the same replayed-evidence class as the inventory restore — but
stamped it with the current graph sequence, so a pane the renderer had already
dropped read as attached for one more statement. The guard below it needs the
tabId and paneKey, not the standing.

Also say plainly in `decideWebSessionTabsSnapshot` what the affirms check does
and does not cover: an unpublished-worktree placeholder is withheld from epoch
noting only. It still applies, because rejecting it outright would drop the
terminal reconciliation that legitimately rides on it.

* fix(runtime): keep the retraction boundary out of the receipt bound

Bounding the removal watermark alongside the receipt ledger reintroduced the
defect the watermark exists to prevent: past 512 retracted worktrees, evicting
a boundary readmits every pre-close frame it was fencing, and a delayed list
resurrects the closed tab. A boundary is not a cache. One number per worktree
ever retracted on an environment is the cheaper price, and environment teardown
drains it; only the receipt ledger stays bounded, by frame age.

Split the orphan-adoption port by provenance so the last writer that disagreed
with the surface-standing rule stops disagreeing. `adoptRuntimeTerminalOrphans`
replays the persisted binding when the claim already matches it and writes a
new one otherwise, and both went through a single `recordSurface` that stamped
the current graph sequence — so re-adopting an already-adopted orphan lifted a
dropped pane's stale stamp and reported it attached, in a quiet workspace
possibly forever. The replay now names the pane without standing and the fresh
claim takes spawn standing, like every other writer.

Replace a receipt-count assertion that was vacuous for a map keyed by
environment and worktree with the mirror state and freshness it was standing in
for.

* fix(runtime): keep a closed-tab worktree under the epoch already publishing it

`closeHeadlessMobileTerminalTab` minted `headless:<now>` on every close. Its
sibling headless writers carry the stored `publicationEpoch` forward and mint
only when there is no snapshot to inherit from — because a write to a worktree
is not a claim to publish it. The close was the one writer that claimed.

A paired client retires the epoch a new publisher displaces, and the web
mirror's retirement is final: there is no revive lane, and the per-worktree
tracking teardown deliberately keeps the epoch history. So an ordinary close
published a stranger for a worktree the renderer generation still owned, retired
that generation on every client, and the renderer's next publication — carrying
the epoch the close had just retired — was rejected forever. The user emptied a
workspace, created a terminal, and it never arrived on either machine while
`session.tabs.list` showed the host holding it.

This is the same thesis the retraction path already states, through the door
next to it: a retraction is not a handover, and neither is a close.

Measured on `paired-two-client-emptied-workspace-reseed.spec.ts`, six runs each:
phase 2 failed 3/6 before (`A=null B=null`, both clients blind for the full 30s
budget) and 0/6 after, with both clients adopting in single-digit milliseconds.

* fix(lint): give the fixtures real types instead of casting past them

The casting gate failed on eight assertions this branch added. All eight were
suppressible, but the suppressions were not the problem: the casts were hiding
fixtures that did not match the contracts they stood in for.

`sessionStillHoldingBothPanes` built tabs as `{id, title, type}` — `type` is not
a `TerminalTab` field and eight required ones were missing — and layouts holding
only `ptyIdsByLeafId`. `as never` made both compile. They are now real
`TerminalTab` / `TerminalLayoutSnapshot` values, so the fixture is checked
against the type `listTerminals` actually reads.

`terminalTab` in the epoch suite built a *client* tab (`status`, `terminal`) for
a field typed with *snapshot* tabs, which forced `as never` at the call and a
cast on the snapshot itself. Production reads only `type`, `parentTabId`,
`leafId`, `ptyId` and `parentLayout` from that tab, so the two client-only
fields were inert; dropping them lets the declared
`RuntimeMobileSessionTerminalTab` type the fixture end to end, and the closed tab
is now held by name rather than recovered from `snapshot.tabs[0]`.

The remaining three casts are unchanged in kind and now carry correctly placed
SAFETY rationales: reaching a protected member is the only way to drive these
paths. `graphSequence` folds into the reach-through that was already there
rather than opening a second one, and the map read narrows instead of asserting.

Mutation-tested, all three suites, regression re-introduced for each:
- epoch mint on close restored -> 1 failed | 1 passed
- orphan check reverted to self-consistency -> 4 failed | 4 passed
- placeholder retirement guard removed -> 1 failed | 7 passed

src/main/runtime 8169 passed | 31 skipped; src/renderer/src/runtime 1581 passed.
`check:code-quality:changed` goes 8 findings -> 0. `pnpm tc` clean.

* fix(runtime): stop the headless placeholder graph from dropping every restored pane

A headless server publishes one empty graph at launch so status clients see a
ready server. It names no renderer pane and is never replaced, but it was
counted as an authoritative graph statement all the same: `graphSequence` went
0 -> 1 while the leaf map stayed empty for the life of the process.

Every surface claim written without standing - a persisted replay, an inventory
restore, the TUI-owner recovery - is stamped 0. Against `graphSequence` 1 the
`>=` guard fails, the empty leaf map answers "no pane holds this", and the
terminal reports `orphaned: true` under a `pty:` tabId. Nothing can re-stamp it,
because the only graph that host will ever publish has already been published.
On a headless or SSH host that is permanent, and it is the same lie #18191 is
about, pointed the other way.

The placeholder no longer spends a graph statement. A renderer graph still does,
so a pane a real graph drops is still reported dropped - including on a desktop
window promoted from headless, which the third case pins as a negative control.

Mutation: restoring the unconditional bump fails the first two cases
("expected 1 to be +0", "expected true to be false"); the promoted-window
control passes either way, as a control should.

Also registers tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
in the cross-version-wire job. The file matches CROSS_VERSION_WIRE_PREFIXES, so
adding it had switched the job's gate on, but the job runs an explicit file list
that omitted it - the test executed nowhere in CI. It passes 8/8.
2026-09-18 01:56:07 -07:00
Neil ba7e4dc85b fix(editor): preserve prose markdown entities 2026-09-18 01:56:06 -07:00
Jinwoo Hong 002ff3ddb8 feat(mobile): per-host generation store for the mobile web bundle (OTA phase B, 2/4) (#21409)
* refactor(mobile): export the mobile web manifest read schema

The generation store re-parses the manifest it cached, and it must read it back
with the same loose reader the fetch accepted it under: parsing strictly after
accepting loosely would turn a host's added field into a forced redownload on
every launch.

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

* feat(mobile): add the per-host mobile web generation store

Turns a verified bundle into an atomically activated, host-scoped generation
directory under the OS cache, and reads it back. No RPC, no UI, no flag: the
native view is later handed the directory read-only and never writes to it.

The single directory under `generations/` is the activation, so there is no
activation file to edit: a commit deletes every other generation before the
rename, an interrupted one leaves zero generations for the runbook's redownload
rule, and two directories or an unreadable manifest drop the host tree instead
of guessing. `tmp/` is never an activation candidate and every one of them goes
at launch. `hosts.json` carries recency only, so losing it costs eviction order
rather than a generation.

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

* fix(mobile): never evict the host a commit just activated

`now()` is a wall clock. With four hosts cached, one backward jump made the
fifth commit's own entry the oldest, so it evicted the host it had just
activated and handed back an ActiveGeneration whose directory was gone.

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

* Revert "fix(mobile): never evict the host a commit just activated"

This reverts commit a082ac1777. That commit carried all six round-1 fixes under
a subject naming only one of them; the six land again below, one per commit.

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

* refactor(mobile): drop the generation store's unused eviction entry point

`evictHostsBeyond` had no caller: commit enforces the four-host ceiling itself,
and a launch-time sweep for a shrunk limit can be added when something shrinks
it. The two `createDirectory` calls went with it, since the port already creates
intermediates, plus a line on what the Android rename fallback leaves behind.

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

* fix(mobile): never evict the host a commit just activated

`now()` is a wall clock. With four hosts cached, one backward jump made the
fifth commit's own entry the oldest, so it evicted the host it had just
activated and handed back an ActiveGeneration whose directory was gone.

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

* fix(mobile): fold case when refusing an asset named like the manifest

APFS and NTFS are case-insensitive by default, so `Manifest.JSON` landed on the
store's own `manifest.json` and the activation read back as the asset's bytes.

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

* fix(mobile): keep a completed activation when the recency index cannot be written

`hosts.json` is written after the rename, so a disk that filled between the two
turned a generation already on disk into a thrown commit. The index carries
recency, not truth, and the next activation rewrites it whole.

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

* fix(mobile): refuse to commit a staged handle whose tree is gone

Commit deleted every other generation before it looked at the staged tree, so
committing an aborted or swept handle destroyed the live generation and only
then threw. The check moves ahead of the first delete.

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

* test(mobile): close four surviving generation-store mutants

Sweeping only the first host's tmp, staging over residue, dropping the serial
queue, and dropping the stale-index pruning all passed the suite. The stage
race needed two differing asset lists under one build id to be visible at all:
with identical ones an interleaved pair ends on the same bytes as a serial one.

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

* fix(mobile): activate only a build-id entry that holds a manifest

An entry under `generations/` matching the staged build id was taken as the
activation on its name alone, so an empty directory of that name — what a
crash between the rename and the post-rename check leaves on Android under
API 26 — or a plain file made the commit drop the verified staged tree and
return a generation that cannot be read back.

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

* fix(mobile): never delete a cache because a manifest read failed

The adapter mapped every `file.text()` throw to null and the reader treated
null as corruption, so one iOS data-protection or I/O blip deleted the only
verified generation a host had. Missing stays null and still drops the tree;
a failed read now throws, and the reader returns no activation without
touching disk, leaving the caller to redownload.

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

* refactor(mobile): drop the generation store's unreachable build-id guard

`MobileWebBundleManifestReadSchema` already pins `buildId` to the sha256
pattern, so no manifest reaching the store can fail the second check.

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

* test(mobile): pin that eviction ignores a non-host directory

Dropping the host-key filter in `listHostDirectories` passed the whole suite;
the ceiling would then count and evict anything else under the OS cache
directory.

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

* fix(mobile): keep a mid-download host out of the cache ceiling

A host holding only a staging tree was counted against the four-host limit
and, having no index entry, sorted first for eviction, so four cached hosts
plus one download meant the next activation deleted the tree that download
was about to commit. The ceiling now counts hosts with a generation; sweeping
still walks every host directory.

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

* fix(mobile): record recency when a commit finds the build already active

The same-build early return skipped the index write, so a host that
redownloaded the bundle it already had stayed the least recently activated
and was the first evicted. No eviction pass on that path: the host count is
unchanged.

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

* fix(mobile): honour only staged handles the store itself issued

`StagedGeneration` is structurally typed, so any object of that shape made
`commitGeneration` rename over, and `abortStagedGeneration` delete, a
directory of the caller's choosing. Handles are tracked in a per-store
`WeakSet` and anything else is refused before a filesystem call.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 04:44:36 -04:00
Neil 45fcfef994 fix(editor): preserve ordered list continuation columns 2026-09-18 01:42:46 -07:00
Neil 150f834fe6 fix(editor): satisfy code span serializer type checks 2026-09-18 01:32:20 -07:00
Neil 549e93cb26 fix(editor): preserve inline code span padding 2026-09-18 01:30:28 -07:00
Jinwoo Hong 1e7a69710d feat(mobile): update wall for the desktop-served mobile web bundle (OTA phase B, 1/4) (#21411)
* feat(mobile): decide whether a web bundle may open against its host

A pure verdict for the bundle update wall, ordered so the answer names the
soonest cause: a host with no bundle has no manifest to disagree about, and an
unknown manifest schema makes the protocol window inside it unreadable. Same
`?? 0` defaults as `evaluateCompat`, so an absent status field reads as the
oldest host that could have answered rather than as permission.

Every blocked verdict is terminal. There is no native workspace fallback, so
each one carries the numbers it compared for the support breadcrumb.

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

* fix(mobile): read an unknown bundle schemaVersion through to the wall

The client reader pinned `schemaVersion` to the one schema this shell knows, so
a future schema 2 failed the parse before `evaluateMobileWebBundleCompat` could
call the shell too old. The user would have seen a transport error where the
update wall belongs.

The host's own manifest stays closed in both directions, where it is written.
Goldens are unaffected: every recorded reply carries schema 1.

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

* feat(mobile): give the block screen copy for the bundle walls

One component still renders every wall. `updateSide` picks the app to update
from the reason, so the copy and the store link cannot disagree, and a new
reason is a compile error there rather than a mobile title over a desktop
button. The existing protocol copy is unchanged.

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

* fix(mobile): type the bundle protocol window the update wall compares

The loose reader left `runtimeProtocolVersion` and
`minCompatibleRuntimeProtocolVersion` as unknown index members, so the parsed
manifest could not reach `evaluateMobileWebBundleCompat` without a cast. Both
are now read as non-negative ints, and the reply-schema test pins it at the
call site: the wall is invoked on a parsed manifest, so dropping either field
stops compiling.

A host that omits the window is now refused. Only a host too old to advertise
`mobileWeb.bundle.v1` can send one, and the phone never asks such a host for a
manifest. The probe test's fake manifest gained the fields it was missing,
which is the typed reader catching its first stale fixture.

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

* refactor(mobile): say whether a bundle verdict actually checked a manifest

`ok` meant two different things: the manifest was read and its window contains
the host, or no manifest had been read at all. A caller that mounted on the
second would mount an unchecked bundle, so `manifestChecked` separates
permission to fetch from permission to open.

The host-status input is now a `Pick` of `HostStatusReply` instead of a
hand-copied pair. Both fields default through `?? 0`, so an upstream rename
would have silently blocked every host rather than failing a build.

Drops two assertions that restated the module's own literal back at it. What
proves today's bundle opens is that the shared contract's schema version is a
member of the supported list, so that is the assertion left standing.

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

* fix(mobile): offer a refetch, not a store, for a bundle the host outgrew

`bundle-incompatible` on the mobile side means the workspace cached for this
host is older than the host's client floor. A store update cannot clear that
and a reconnect can, so the screen no longer sends the user to a download that
would change nothing. The button is gone rather than relabelled, because the
recovery is leaving this screen, and the note drops its "already updated?"
opener for the same reason.

`blockRemedy` replaces `updateSide` and is now passed to the copy instead of
recomputed there, so the title, the body, and the button are decided once.

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

* test(mobile): drop the platform assertion from the block-screen mock

The mocked `Platform.OS` was widened with an assertion so a test could switch
stores. An annotation on the binding does the same widening in a position the
compiler checks, which is what the changed-code quality gate asks for.

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

* docs(mobile): say which bundle-compat default is fail-open, and drop a dead field

Both comments claimed the two host-status defaults point the same way. Only
`protocolVersion` is absent-means-oldest. An absent `minCompatibleMobileVersion`
is `?? 0`, which is no floor at all, so the mobile arm is fail-open by design
and matches `evaluateCompat`. A reader taking the old sentence at face value
would have gone looking for a bug.

`supportedSchemaVersions` had no consumer on the verdict: the block screen
renders a title and body, and B4 reads neither. The exported constant stays,
since that is what the wall is decided against.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 04:27:45 -04:00
Neil 3dd62ec141 Revert "fix(editor): keep emphasis outside a code span on serialize"
This reverts commit 22eda4ba82.
2026-09-18 01:25:22 -07:00
Frederic Barthelemy 22eda4ba82 fix(editor): keep emphasis outside a code span on serialize
Bold wrapping a code span came back inverted: ``**`B93934206`**``
serialized to `` `**B93934206**` ``, turning bold text into literal
asterisks.

ProseMirror ranks a text node's marks by schema order, which Tiptap
derives from extension priority, so the parser's correct order was
renormalized. Raise code above emphasis; link stays below code so
linked code labels keep serializing.
2026-09-18 01:25:01 -07:00
Frederic Barthelemy b22fc02610 fix(editor): require non-space delimiters for inline math
The upstream tokenizer matched `$([^$]+)$` with no delimiter rule, so a
second dollar sign anywhere in the paragraph turned prose into a math
span: `Costs are US$ 5,000 and R$ 40,000` lost the space after `US$`.

Require a non-space next to each delimiter and forbid a newline inside,
matching the common dialect. Real math still parses.
2026-09-18 01:22:20 -07:00
Neil fd1c8847bb fix(editor): preserve bare details source tags 2026-09-18 01:14:06 -07:00
Neil 9d1826ae65 fix(session): repoint the rows a worktree re-key strands (latent; producer is flag-disabled) (#20057)
* fix(session): keep a renamed worktree's rows from matching on the id it lost

Three persisted session fields survived a worktree re-key still naming the old
identity. Two of them are suppression records, so a stale id does not read as
residue -- it silently re-admits state the user removed:

- closedTerminalTabTombstonesByTabId: the remote merge only suppresses a host
  tab when the tombstone's worktree equals the tab's, and no snapshot ever
  covers the old id, so the tombstone never retires either.
- clientHostedBrowserCloseIntentsByEnvironment: the replay targets the intent's
  worktree, and an unresolvable selector answers selector_not_found -- which the
  replay reads as definitively gone and uses to DROP the intent.
- clientHostedBrowserPagesByWorktree: keyed by worktree and re-checked against
  the row's own workspaceId, so both halves have to move or the pages are never
  rehydrated.

Fixed on both sides of the rename: the main-process persisted migration and the
renderer's live store, which would otherwise write the stale values straight
back. The coverage test drives off WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND,
the census these three fell out of, with the shipping owner collector as its
oracle.

* docs(session): record why a re-key clobbering an existing target stays unfixed

Not a missing guard -- an unresolvable one. Keeping the target is correct when
it holds a real closed-last-terminal tombstone; keeping the source is correct
when the target row is a stub; nothing records which is newer. The recency map
is the only one that can settle it, because Math.max needs no such ordering.

* test(persistence): measure the downgrade direction for worktree identity

The stack widens migrateWorktreeIdentity to repoint worktreeId inside
session rows. That changes what lands on disk with no wire change, which
is Rule 3's shape applied to persistence, so it is measured against
v1.4.199 rather than reasoned about.

Result: new-build state does not break the old build. The old build
renames over it without throwing and loses no row; the two row kinds it
cannot repoint stay stale, which is exactly what its own renames already
produce.

The numbers are measured. A first draft asserted the old build repointed
no inner rows at all; it repoints two of four, and the probe is what
caught that.

* test(ci): run the worktree-identity downgrade lane instead of describing it

The cross-version job names its files explicitly, so a new one is inert until
it is listed; the sharded unit job excludes the whole directory and the E2E
router only takes `*.spec.ts`. Also pairs the forward-compat case against the
current build — the stack's own field-list walk is the guarantee that matters,
and only the frozen build was exercised.

* refactor(session): drop the type assertions the rename migration leaned on

`consistent-type-assertions` landed on main after this branch last built, and
three of the `as never` fixtures were hiding real contract drift: a browser
workspace row missing six required fields, a tab group naming three fields the
type does not have while omitting the two it requires, and a sleeping-agent row
whose `providerSession` had neither `key` nor `id` and whose `state` was not in
`AgentStatusState`.

Indexing the session by a computed field name is what forced the casts in the
migration, so the four row maps are now spelled out; the census test is what
keeps a fifth from joining silently. The renderer test builds its state from
the real slice instead of casting a four-field partial.

* refactor(test): name the module namespace the skew harness reads

`object` is too broad for the anti-slop gate, and the import helper already
declares what it hands back.

* docs(test): say which maps the harness actually supplies

The two under test live in slices this harness does not mount, so calling it
"the real slice's state" overclaimed.
2026-09-18 01:11:03 -07:00
d139760c06 fix(sessions): cancel transcript acquisition during host teardown (#21006)
* fix(sessions): cancel TUI transcript acquisition during teardown

* fix(sessions): settle canceled handoffs without replacement launches

* test(sessions): assert fenced teardown release

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-18 01:10:32 -07:00
Frederic Barthelemy 80a4d0660f fix(editor): preserve the legacy details styling class on round trip
A file saved by an earlier Orca version carries class="orca-details"
in its markdown source. The prior commit stopped the serializer from
ever writing that class, which also stopped it from preserving one a
source already had, so re-saving such a file dropped the class and
the round-trip eligibility check then failed to recognize the file as
its own, regressing it back to Source mode. The details node now
carries a flag set only when its source opening tag had the class, and
the serializer re-emits the class only when that flag is set.
2026-09-18 00:58:57 -07:00
Frederic Barthelemy 68cf9ea08a fix(editor): keep rich mode for documents with details blocks
The markdown serializer always injected class="orca-details" into
saved <details> tags. The rich-mode round-trip check compares the
serialized output against the source's literal opening tag, so any
user-authored <details> without that class failed the comparison and
fell back to Source mode, making the details extension unreachable
from a file. The class is already applied to the rendered DOM node
independently, so the serializer no longer needs to write it into the
markdown source.
2026-09-18 00:58:47 -07:00
Neil be766569bd fix(editor): validate reference syntax tree nodes safely 2026-09-18 00:55:38 -07:00
Neil 1735bcb1b1 fix(editor): classify reference syntax outside HTML comments 2026-09-18 00:53:44 -07:00
Luke Son 01a33bc427 fix(git): respect existing .orca ignore rules
Respects effective local, WSL, linked-worktree, runtime, and SSH Git ignore rules before updating .gitignore. Fixes #21212.
2026-09-18 00:53:09 -07:00
Frederic Barthelemy 5f636c1a93 fix(editor): make the reference-link pre-filter linear-time
The nested (?:[ >]*(...)?)* quantifier let a long run of leading
spaces or > with no closing [ trigger catastrophic backtracking,
freezing the renderer on any document with such a line. Replace it
with a single non-nested character class, which is linear and safe
to over-admit since the parser confirms every candidate.
2026-09-18 00:49:32 -07:00
Frederic Barthelemy 0c7661700b fix(editor): cap the reference-link definition parse like the HTML round-trip
hasLinkReferenceDefinition ran a full remark parse on every content
change with no size guard, unlike the HTML round-trip check beside it.
Above the same 50,000-char cap, the pre-filter match is now trusted as
a definition, keeping rich mode blocked rather than risking an
unparsed document opening in it.
2026-09-18 00:49:25 -07:00
Frederic Barthelemy c76729452c fix(editor): admit arbitrarily nested containers in reference-link pre-filter
The pre-filter only matched one list-or-blockquote transition, so a
definition under e.g. list-then-blockquote or three-level nesting
skipped the parser confirmation and rich mode stayed on for content it
cannot round-trip.
2026-09-18 00:48:49 -07:00
Frederic Barthelemy 2feea7ce4a docs(editor): tighten reference-link detection comments 2026-09-18 00:48:06 -07:00
Frederic Barthelemy d4f84320ad fix(editor): stop treating prose after [label]: as a link definition
The reference-links check only looked for a line starting with
[label]: followed by non-whitespace, so prose like "[Bug]: steps to
reproduce…" tripped rich-mode's fallback to Source mode. CommonMark
only treats [label]: as a link reference definition when the rest of
the line is a destination plus optional title, so detection now
parses the document with remark-parse/remark-gfm and checks for an
mdast definition node instead of guessing the shape with regex.
2026-09-18 00:48:02 -07:00
Neil a44ab995f9 fix(editor): preserve transport-protected table content 2026-09-18 00:46:43 -07:00
Jinjingandaverydev 93ee27ca2e fix(editor): preserve markdown backslash escapes that change meaning on save
Re-parse each block after serialize and keep the candidate that still
matches the document, preferring fewer escapes. Restores \# / 1. / \| /
\$HOME\$ / link dests that 3.31.3's escapeMarkdownSyntax drops.

Co-authored-by: averydev <averybloom@gmail.com>
2026-09-18 00:43:05 -07:00
Jinjingandaverydev 7b33cd573d fix(editor): preserve source bytes and trailing newline on end-of-file saves
Align the canonical diff inputs with the source final newline when getMarkdown omits it, so EOF edits land before that newline and keep untouched source bytes. Preserve the source line-ending style on every canonical fallback.

Co-authored-by: averydev <averybloom@gmail.com>
2026-09-18 00:42:47 -07:00
Neil 34651306f3 chore(editor): keep details parser within lint limits 2026-09-18 00:38:41 -07:00
Neil 56fb61d02b fix(editor): recognize uppercase details tags in markdown scans 2026-09-18 00:36:08 -07:00
Frederic Barthelemy df20f32fc3 fix(editor): close a fence only on spaces or tabs
The closing-fence pattern used `\s`, which also matches non-ASCII
whitespace such as U+00A0. A closer followed by one ended the fenced
range early, so a `<details>` block still inside the fence reached the
details tokenizer and was rewritten as editable markup.
2026-09-18 00:35:28 -07:00
Frederic Barthelemy a070b00dda test(editor): guard the details start hook's early exit by scan count
The 1000ms ceiling passed even with the early return removed, since the
guarded per-paragraph scans cost far less than the bound on this input.
Assert markdownFenceRanges/markdownCodeSpanRanges call counts instead:
zero for a toggle-free document, one per call whose remaining source
holds the toggle. The wall-clock check survives as an opt-in benchmark
gated by ORCA_DETAILS_SCAN_BENCH, matching this repo's existing
bench-test convention.

# Conflicts:
#	src/renderer/src/components/editor/markdown-scan-ranges.test.ts
2026-09-18 00:35:27 -07:00
Jinwoo Hong c263f5d092 chore(mobile): repin the RPC recording baseline to main after #21374 (#21402)
#21374 squashed to 60a774c30c, which main does not contain, so the pin guard's ancestry check is red on main; main has since moved past that commit and src/shared changed, so the pin is main's tip 1e3795de99 rather than the squash sha.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 03:34:30 -04:00
Frederic Barthelemy 7bb1838cab fix(editor): treat tag-shaped text in code as prose when validating toggles
The nested-toggle strip handed a <details> candidate inside a code span
to the block matcher, which consumed the span's closing tag and left the
containing block unmatched. The editability sweep then rejected any
remaining tag-shaped text, so a body quoting details markup fell back to
passthrough HTML.
2026-09-18 00:33:12 -07:00