* feat(agent-launch): carry the launch inputs the host cannot derive
Desktop's launch call sites cannot move onto `agent.launch` while the wire
drops inputs they depend on. This adds the three the host genuinely cannot
work out for itself, and deliberately adds nothing the host can.
- `agentArgs` — the host read only `settings.agentDefaultArgs`, so a saved
launch recipe's arguments had no way across. Tri-state is preserved: `null`
is "no arguments", absent is "use the settings default".
- `cwd` — `TerminalCreateOptions.cwd` already reached the spawn, but nothing
on the wire filled it. It also decides the route: only a terminal can start
somewhere other than its workspace, so the host now feeds it to
`requiresTuiLaunchCommand` and downgrades with `tui_launch_command` rather
than running a structured session in the wrong directory.
- `launchSource` — telemetry, and the only member of the `agent_started`
triple the host cannot derive; `agent_kind` and `request_kind` are computed
host-side. Typed `z.string()`, not the closed enum: params are validated by
the HOST, so a closed arm set would let an older host refuse a newer
client's launch over a label. Attribution must not gate a user action.
Not added, because the host already derives them: `launchPlatform`
(`getAgentLaunchPlatformForWorkspace`, from the same connectionId/path/
projectRuntime the renderer uses) and `startupCommandDelivery` (a pure
function of the agent inside `buildAgentStartupPlan`).
Fingerprint: `agentArgs` and `cwd` are in — they change what the call does, so
a retry carrying different ones must conflict rather than replay.
`launchSource` is out — two buttons producing the same launch are one
operation, and folding it in would refuse an honest re-attributed retry. A
caller sending none of the new fields digests exactly as before, because the
canonicalizer drops undefined keys, so launches admitted by an older build
still replay across the upgrade.
Arguments reaching a structured route are ignored by an existing deliberate
decision (the Agent SDK and app-server version their option sets separately
from the interactive CLI), so the host reports it in `warning` instead of
overriding the user's preference on the strength of a field that is not
evidence about the surface.
* fix(agent-launch): forward create-target launch inputs
C7.7 (#21977) measured the session route's page closure at 4,330 on a
merge of f07bf8544c and gated green. Before it merged, #21924 turned
agent-session-wire.ts's type-only import of agent-session-record into a
value import, so src/shared/agent-session-record.ts and the two modules
it reaches, agent-session-conversation-name.ts and
surrogate-safe-text-slice.ts, entered the page bundle. Pristine main at
3cfb070294 reads 4,333. Named by diffing the closure at f07bf8544c
against 2739246058; nothing on the C7.7 side moved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): switch the session route to the shell, still unregistered (OTA phase C, C7.7)
The review switch's shape, for its reasons. The session screen becomes
`MobileSessionRouteScreen` in `src/session` because `useMobileSessionController` is 32 hooks
deep and opens the terminal, chat and tab subscriptions: at the switch's top level it would
open every one of them behind the page as well as in front of it, since hooks cannot be
conditional. As an element passed for `fallback` it is built and not mounted.
Four query params carried rather than re-derived, each omitted when empty: `name` is a label
the screen otherwise derives from the workspace, `created` is the create flow's one-shot flag,
`warning` is the host's own text, and `paneKey` is a notification tap. `paneKey` is the one the
screen writes back — `use-notification-pane-navigation.ts` rewrites it to empty once it has
switched, through `setParams` on the handoff, which inside the page is the document's own
router — so it has to arrive in the page for that to happen at all.
Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists
it; until then the flag is the only thing that changes and it is off.
Three censuses red without their rows, measured on this tree:
- `shell-screen-route-census.test.ts` `walks the route tree and finds them` named
`session/[worktreeId].tsx` as a ninth switch the list did not have.
- `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no
others` reds without `SESSION_ROUTE` in `SWITCHED_ROUTES`.
- `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` named the
new sibling.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): root the session parity family at the screen the route mounts (OTA phase C, C7.7)
The extraction parity pin walks from a root function in `app/h/[hostId]/session/[worktreeId].tsx`,
which is now the flag switch: the walk found no `SessionScreen`, and the runtime-string count went
534 -> 542 on the switch's own param names and path literals.
Rooted at `MobileSessionRouteScreen` instead, which is the function that calls the controller. The
switch's business is which of the two screens renders, not what the session screen does, and its
literals have no place in a hash about the extraction.
Every pinned hash is unchanged, which is what says the body moved and nothing else did: 275 hooks,
77 callbacks, 24 effects, 534 runtime strings, 124 host and 61 leaf JSX facts, 172 style
references, all at the same SHA-256 they had before the move.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the page the session screen's stored preferences (OTA phase C, C7.7)
Ruling 7: nothing silently no-ops. The allowlist was one exact key and one prefix, so every
preference the session screen reads inside the page fell back to its default and kept working
outside it — a state the user cannot tell from a preference that does not exist.
The keys are derived from the route's own closure, not copied from design §6. Nine join the list:
`orca:terminal-accessory-layout`, `orca:custom-accessory-keys`, `orca:defaultSessionView`,
`orca:mobileStructuredSendOperations:v1`, the three terminal preferences ruling 7 names
(`orca:terminalTextScale`, `orca:terminalAutocompleteEnabled`, `orca:terminalLinkOpenMode`), and
two the design did not: `orca:hostDockWidth`, which `use-mobile-dock-resize.ts` drags on this
screen, and `orca:hostSidebarWidth`, which `app/h/_layout.tsx` reads above every page route and
which the manifest already names as the reason agent-history declares `storage` at all.
Two are per workspace, not per host. Design §6 has `orca:nativeChatTabs:<worktreeId>`; the module
builds `<prefix><enc(hostId)>:<enc(worktreeId)>`, and `orca:terminalLiveInputDisabled:` has the
same shape. So the narrowing goes one level in from C2.9's: `pageStorageKeysForRoute` and
`isPageStorageKeyForRoute` replace the host-scoped pair, and a session page opened on one workspace
can no more rewrite the tabs of the one beside it than it can another host's pins. Both sides read
the workspace off the route pathname, which is the one fact the shell and the page are each handed.
Every new key's writer notes the mirror before it persists, as `savePinnedIds` does: `init` is
built synchronously, so a write that only reached the store would be one `init` behind.
A refusal is a rejection, not a dropped write. The real AsyncStorage rejects when its store
refuses, and the caller that matters already catches: the durable send journal answers
"Message not sent" rather than putting a mutation on the wire with an operation id no store holds,
which after a crash would send the message twice. `PageStorageRefusedError` names the key and which
of the three refusals it was.
Measured on this tree, which is why the journal needed more than an allowlist entry: one journal
entry with no attachment serializes to 342 characters and 48 unsettled sends put the value past
`PAGE_STORAGE_MAX_VALUE_CHARS` (47 is under it), against a schema that admits 4,096. `init`'s own
`BridgeInitStorageSchema` refines on that bound, so handing the journal over whole refuses the
*frame* and the session screen never opens at all. `pageStorageEntriesForInit` drops such a value
and names it; the page reads a default, which is a degradation rather than a page that does not
start.
Red first, measured here:
- 21 cases across three files on the host-scoped helpers being gone.
- `leaves out a value the page would refuse the whole frame over` reds with the filter bypassed.
- The journal case reds without the rejection, with the operation claimed against a store that
never took it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): register the session page route (OTA phase C, C7.7)
One entry in `MOBILE_WEB_PAGE_ROUTES` with ten grants, every one read off a call site in this
route's own closure rather than carried from design §1. Measured here:
navigate 7 handoff sites
externalLink 6 openers
haptics 24 trigger sites
native.clipboard.write 6 sites
native.clipboard.read 3 sites
native.media.* 2 sites, one seam (`useMediaPicker`)
screencastBinary 1 site (`MobileBrowserPane.tsx`)
storage 10 exact keys and 2 workspace-scoped, the previous commit's
`pageRouteGrants` is derived from this list, so the row is a consequence of the entry and there is
no second table to edit. The design's list was exactly right; the counts are what say so.
The hop census goes 16 -> 23, measured. All seven new rows are `X -> /h/[hostId]/session/
[worktreeId]`, one from each other page route, and none goes the other way: the session's ten
grants are a strict superset of every other route's, so every hop into it is handed to the shell
and every one of its own targets stays in the document. That second half is asserted as grant
coverage rather than as the absence of seven rows — absent is also what an unregistered route
looks like, which is the shape C4 already had to correct once.
Two censuses gained the route and one is new:
- The haptics seam census, whose route-module map moves to
`mobile-web-app-page-route-modules.mjs` so the new census below shares it rather than keeping a
second copy that stops growing when the first one does.
- `page-served-back-control-a11y.test.ts`, which named two controls with no `accessibilityRole`:
`MobileSessionHeader.tsx:64 role=none label=Back to worktrees` and
`QuickCommandsSheet.tsx:160 role=none label=Back`. Both get the role. Inside the shell there is
no native chrome behind them, so a bare Pressable is absent from the accessibility tree.
- `mobile-web-app-screencast-lane-grant.test.mjs` derives `screencastBinary` from the closures the
way the haptics census derives its token. C6 could not write it: the pane is mounted by a route
rather than registered as one, so there was no route to pin the grant against (C6 ruling 3).
The derivation census gains C6's half measured against this route rather than against a module
closure read on its own, which is the other half of C6 ruling 3. The composed row for the session
route's own families waits on C7.8's table, and on C4.5's split before it.
Numbers, both ends measured on this tree, never summed:
- Session route closure 4,328 -> 4,329 modules, 978 -> 979 local. The +1 is
`MobileSessionRouteScreen.tsx`; the route file is one input either way, now the `.web.tsx`.
- Chunk count 65 before and 65 after, against the 72 the fence allows at 14 route keys. The fence
is untouched: a `.web.tsx` sibling is not a new route key, and this route shared its split.
- Bundle 8,020,519 -> 8,022,202 bytes, 108 assets either side.
`mobile-web-app-route-chunk-closure.mjs` looked the route module up by its exact path, and
`resolveExtensions` puts `.web.tsx` first: the first route with a sibling to be asked for reached
"no output". It tries the sibling first now, which is what the build actually chunked.
Without the manifest entry these red on this tree: `pins every hop the handoff must take away from
the page`, `keeps every hop out of the session local`, `declares only routes the bundle has a
module for`, `reaches the built manifest`, `covers every page route and finds a control in each`,
both haptics-seam cases, and `declares the screencast lane on exactly the routes whose closure
asks for it`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): render-check the session route, and quiet the two things it found (OTA phase C, C7.7)
The render check mounts the registered route in a real browser on the built bundle, under the
header the shells send. It asserts the session screen paints rather than the Unmatched route, that
the Back control reaches the accessibility tree as a real `<button>` with its name, that the
route's own chunk arrives on a client-side navigation, that nothing it paints leaves the origin or
logs a policy violation, and that the three reads the screen makes carry the workspace the route
named — the precondition the rest needs, since a screen that mounted and asked for nothing would
paint the same chrome.
It also asserts, strictly, that the page and console errors are `[]`, which is what found both
fixes here. Measured on this tree before them: two console lines and one uncaught rejection on
every mount of the route, none of them visible natively.
- `use-mobile-session-markdown-actions.ts` registered `BackHandler.addEventListener` with no
platform guard, and the effect re-registers whenever the dirty-draft list changes. React Native
Web answers "BackHandler is not supported on web and should not be used." and hands back an inert
subscription, so the guard was never armed on the page anyway. Gated on `Platform.OS`, as the
right drawer, the bottom drawer and the file preview already are. There is no hardware back in a
WebView; the shell owns the phone's, and the page's Back control is where the prompt lives.
- `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch.
The loader returns on a *refused* `worktree.show` and nothing caught a *rejected* one, so a host
that will not answer produced `Uncaught (in promise)` on every session mount. Caught at the
effect rather than inside the loader, whose promise the golden recorder awaits; notes that did
not arrive leave the ones on screen as they were, which is the module's own policy for a refusal.
**The terminal is not painted here and the file says so at both ends.** A terminal on screen needs
the host protocol handshake, a tab snapshot, a terminal inventory and a `terminal.subscribe`
stream — five hand-written fixtures against five Zod schemas inside a transport double, which is
what the harness's docstring refuses to become. Scripting `status.get` alone was measured here:
the protocol gate reads it and the page paints "Update Orca on your computer" instead of the
screen. What the terminal does under the shipped header is
`mobile-web-app-terminal-render.test.mjs`, on the same component and the same build options.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): refresh the session parity pins for the two seam edits (OTA phase C, C7.7)
The previous commit's two fixes are inside the parity family, so three pins moved. Both edits are
one token each and neither changes what a phone renders:
- `'web'`, the `Platform.OS` guard the Markdown actions' `BackHandler` registration gained.
- `"button"`, the accessibility role the session header's Back control gained.
Runtime strings 534 -> 536, with the effect hash and the host-JSX hash moving for the same two.
Everything else is unchanged: 275 hooks, 77 callbacks, 24 effects, 61 leaf JSX facts, 172 style
references, all at the SHA-256 they had before. A separate commit because a reported head does not
move by amend, and because the moved hashes are worth reading on their own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): report the diff-notes rejection instead of catching it (OTA phase C, C7.7)
The `.catch` the previous commit added to `loadDiffComments` moved a golden, which is a finding
rather than something to record over: `matrix-session.diff-notes-worktree.show-1` certifies the
unhandled rejection as an effect of its loaded checkpoint, so the corpus says the app raises it
today and a fix is a re-record and a review event.
Reverted to `void loadDiffComments()`, with the defect written where a reader of that effect will
find it. `family-recordings.test.ts > session.diff-notes: reply partitions at worktree.show#1` is
green again; it was the one failure in an otherwise clean 8,699-test run.
The render check keeps the observation rather than losing it. Its error assertion is now the exact
list `['RenderCheckShellDouble: the render check answers no RPC']` instead of `[]`, so a second
error reds it and so does this one going away — which makes the file the place the fix is noticed
when someone lands it with the re-record.
The defect, for that PR: the loader returns on a *refused* `worktree.show` and nothing catches a
*rejected* one, so a host that will not answer raises an unhandled rejection on every session
mount. It is not a page fault — the shell's `fault` notify comes from the React boundary and
nothing reaches it — so the generation is not dropped and the screen works; the cost is a
document-level error on every mount.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the session effect hash after the diff-notes revert (OTA phase C, C7.7)
The effect pin was refreshed while `loadDiffComments` carried a `.catch`; reverting that (the fix
moves a golden, so it is a finding rather than a line) moves the same hash back off it. Repinned on
the uncaught `void` call, which is what the tree holds and what the corpus certifies.
Count unchanged at 24 effects; nothing else in the family moved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): reject a page storage write for size only, and log the rest (OTA phase C, C7.7 round 1)
Ruling 33.4. `PageStorageRefusedError` was raised for all three refusals, and two of them have no
catcher: a page-closure writer of an unlisted key awaits `setItem` with nothing around it —
`notification-delivery-preferences.ts:39` plainly, `preferences.ts` in several places — so a key
the page was never allowed to keep became an unhandled rejection in the document. That is a worse
failure than the silent drop it replaced, and it is the one the page can least afford, because an
uncaught rejection there is a document-level error on a screen that is otherwise working.
Scope is now one refusal. `too-large` rejects, because the caller that needs it is written for it:
the durable send journal's composer catches it and answers "Message not sent" rather than sending a
mutation whose operation id was never written down (ruling 7). `not-allowed` and `not-delivered`
resolve and are logged as `[page-bridge] storage-write-dropped`, which is the old behaviour plus
the line a device log needs — a preference that did not stick looks identical to one nobody set.
A batch applies every pair it can, logs every drop, and rejects only if one of them was oversize.
Red first, measured here: seven cases in `page-async-storage.test.ts` red on the rejection, among
them a `notificationDeliveryPreferences` write resolving, another host's pins, another workspace's
chat tabs, and a write the shell would not take. The oversize case is unchanged and still asserts
`PageStorageRefusedError` with the key and the character bound in its message, so the narrowing is
visible as the difference between the two.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): make every count in the session route say the same number (OTA phase C, C7.7 round 1)
Ruling 33.5. Three numbers were stated more than once and two of them had drifted when the merge
took the grant list from ten to fourteen.
- Grants. `mobile-web-page-routes.mjs:100` and `mobile-web-page-route-hop-coverage.test.mjs:57`
both still said ten. Fourteen in both, and the manifest comment now names the audio verbs beside
the media ones as things only this route asks for.
- Keys. The manifest said `storage` covers "the ten exact keys and two workspace-scoped ones",
which counts `orca:last-visited-worktree` — a key this route did not add. Nine exact plus the
two workspace-scoped, which is what C7.7 put in `page-storage-keys.ts`.
- The journal entry. 342 and 343 are both real and answer different questions, which is exactly
why one number had to win: an entry serializes to 342 characters on its own and costs 343 in the
array, the difference being the comma that joins it. 343 is the one that drives the threshold,
so it is the one stated, with the 342 kept beside it as its derivation. Re-measured here rather
than carried: 47 entries are 16,140 characters and 48 are 16,483, against the 16,384 cap.
Comments only; no behaviour and no assertion moved. The threshold case in
`mobile-structured-send-page-storage-refusal.test.ts` already asserted the boundary both ways and
still passes unchanged, which is what says the arithmetic above is the code's and not the prose's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): red-first for a pane request over a re-sent init (OTA phase C, C7.7 round 1)
Ruling 33.1's four cases plus the compatibility one, all red: `publishRoute`
is not a member of the host, `onRouteUpdate` is not a member of the page's
client, and `ready` carries no `accepts`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): deliver a pane request to the mounted page over a re-sent init (OTA phase C, C7.7 round 1)
Ruling 33.1. The session switch keyed on the whole route, so a notification
tap for another pane of the session on screen either remounted the shell (a
bridge teardown and a page reload for a tab switch) or, for the pane already
showing, moved nothing at all: the page cleared `paneKey` on its own router
and the native param kept it, so `SET_PARAMS` wrote the value already there.
`paneKey` leaves the key and travels as a route update. The page declares
`accepts: ['route-update']` on `ready`; the shell re-sends `init` for a
same-path param change only to a page that declared it, and treats a second
`init` for the session the page already holds as a route update rather than a
replacement -- in-flight requests, subscriptions, the storage snapshot (the
same object, asserted) and the generation all stay. The screen reports
delivery and the switch clears the native param, so no later `init` replays a
spent tap. `use-notification-pane-navigation.web.ts` reads the request off a
standing listener; the native file is unchanged.
Wire-compatible both ways without a version bump: `accepts` is optional, an
older page is never sent a second `init`, and an older shell never sends one.
Both degrade to today's lost repeat tap. `BRIDGE_PROTOCOL_VERSION` and every
released native RPC are untouched.
Two files were at their line cap, so two modules came out at their own
boundaries rather than a cap bump: `bridge-init-route.ts` (the route half of
`init`, wanted by the switches, the host and the page) and `bridge-host-route.ts`
(one host's held route and what it may publish).
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): the session switch and its hardware-back gate get their own tests (OTA phase C, C7.7 round 1)
Ruling 33.2. `mobile-web-shell-session-route.test.tsx` mirrors the eight cases
the files switch has -- route built, native fallback while the flag settles,
repeated params, dot-segment refusal, segment encoding, flag off, remount on a
route change, remount on a param change -- plus the two pane cases: a repeat
tap for the same pane reaches the mounted page twice and a different pane
reaches it once, both with one mount in the lifecycle.
The `BackHandler` gate gets a unit test in the shape of its three siblings.
Reaching it meant the hook declaring the fourteen fields it reads instead of
taking all 268 of the session model, so a probe can render it without building
a session; `MobileSessionDiffCommentsModel` satisfies that by construction and
the one caller is unchanged. No pin in the session parity census moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): a call-site census for the six grants that had none (OTA phase C, C7.7 round 1)
Ruling 33.3. `navigate`, `storage`, `externalLink`, the two clipboard verbs
and the media three were pinned only by the list they were copied from, so
striking any of them out of a manifest entry reddened nothing. Each is now
derived from the route's own closure by parsing the call sites -- a call, not
a mention in a comment or a string, and not an import the module never calls
-- and each row has a named control case driven over the session entry with
that row's grants struck out.
It found one thing. `app/h/_layout.tsx` wraps every `/h` route in
`HostProtocolGate`, whose wall offers an Update Orca link through
`openExternalLink`, and two routes reach that without declaring
`externalLink`: on them the link posts a notify the shell refuses. Recorded
exactly as `KNOWN_UNDECLARED` rather than exempted, because widening two other
routes' grants is a capability decision and this is pre-existing on main.
`notificationPaneTab` moves to its own module. A `.web.ts` sibling cannot
import its native neighbour by the plain path: the bundler's
`resolveExtensions` answers with the `.web.ts` file, so that import was the
file itself and esbuild refused the page bundle with a cycle. The mobile suite
does not bundle, so only the closure walk saw it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): make a struck-out grant red a case named after it (OTA phase C, C7.7 round 1)
The first shape checked the whole manifest at once, so removing any one of
the eight reddened all seven cases and named none of them: the per-row control
read `session.grants` off the manifest the removal had just changed. Each row
now has its own manifest case, and each control is built from what the
session route's closure reaches rather than from what its entry declares, so
it stays green whatever the manifest says. Closures are memoised, which is
what pays for walking all eight once per row.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hold the pane request in a ref, not in state (OTA phase C, C7.7 round 1)
The changed-code quality gate's React Doctor found it:
`no-adjust-state-on-prop-change`. A tap can arrive before the terminals have
loaded, so the request has to wait; holding it in state meant the effect that
consumed it set state on a prop change, and the stale selection renders first.
The request waits in a ref now and a counter wakes the effect, so the effect
reads and clears rather than adjusting anything.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): re-measure the session closure and list the new web sibling (OTA phase C, C7.7 round 1)
The full `config/scripts` suite found both. The closure reads 4,326 modules
and 984 local, two more than the merge, and the two are named rather than
counted: `notification-pane-tab.ts` and `bridge-init-route.ts`. The pane
hook's web sibling replaces the native file rather than joining it, so it
costs nothing -- but it is a `.web.ts`, so it needs its row in
`web-overrides.json` saying why the native one cannot run on the page.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep the page off a journal init could not carry (OTA phase C, C7.7 round 1)
Ruling 33.6, from pullfrog on `beda1cc384`. Dropping an over-cap value from
`init` did not revoke the page's write access to it: the key stays in
`pageStorageKeysForRoute`, so the page read no journal, `parseJournal(null)`
gave it an empty one, and its first send wrote a one-entry value over the
device's -- every native entry lost and a fresh `operationId` for an operation
the native journal already held, which is the duplicate send ruling 7 exists
to prevent.
`pageStorageEntriesForInit` now reports `oversize` beside `dropped`: only the
value-cap drops, because an entry-cap drop is a key that fits and the page's
own write of it is the size the shell would have carried anyway. The shell
sends those names as `init.storageOversize`, and a page write to one of them
rejects with `PageStorageRefusedError` under the size contract of 33.4, which
the composer already shows as "Message not sent". The native journal is
untouched until the user is back on native or it drains.
`storageOversize` is optional in both directions: an older shell sends none
and an older page ignores it, which is exactly today's behaviour. No version
bump; omitted rather than sent empty, so no golden moves.
Red first, with the two states replaced by ones the shell produces. The 47/48
case drives `pageStorageEntriesForInit` rather than publishing a journal value
the shell strips before `publishPageStorage` ever sees it, and the case that
used to assert a successful write now asserts the native entries survive: it
was the clobber, recorded as success.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): deliver a route update only when a param moved, and only after init went out (OTA phase C, C7.7 round 2)
Round 2, findings 1 and 2, both red first.
`onRouteUpdate` fired on every re-sent `init` for the session the page holds,
not only on one whose route moved. The shell answers every `ready` with the
route it holds and the page re-asks on its own backoff and again after a
refused `state` frame, so one tap reached the pane hook as `['', 'pane-1']`.
Both ends now read one definition of moved, `bridgeRouteMoved`, which is the
page's own `shellScreenRouteKey`: the host will not send an `init` for a route
that did not move and the page will not publish one it was sent anyway. The
`.web.ts` hook keeps its empty-pane guard and its comment now says why it is
load-bearing rather than defensive -- the shell's own clear arrives as a move.
`onRouteDelivered` ran on the `ready` path without checking that an `init` had
gone out. A refused route answers the ask with nothing, so the caller would
clear a one-shot param the page never received. `sendInit` reports whether a
frame left and `onPageReady` carries it. Unreachable from the session switch,
which parses the route before it mounts the shell; the prop's contract says it
anyway, and the publish path already honoured it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): attach the init-storage doc to its type, keep the overrides escape (OTA phase C, C7.7 round 2)
Round 2, findings 4 and 5, neither a behaviour change.
The block describing `pageStorageEntriesForInit` had `PageStorageForInit` and
its own one-line doc between it and the function, so it documented neither.
The type moves above it and the block sits on the function it describes.
`web-overrides.json` had an escaped em dash re-encoded as a literal one when
this branch added its rows through a JSON round trip, on a line about the
keyboard stub that has nothing to do with C7.7. Main's `—` is restored;
`oxfmt --check` accepts the file either way, so this is main's spelling kept
rather than a formatter's demand.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): catch the custom-key save the page store refuses (OTA phase C, C7.7 round 2)
Round 2 addendum. `addKey` awaited `saveCustomKeys` with no catch and both of
its callers are `void addKey(...)`, so the rejection had nowhere to go.
`orca:custom-accessory-keys` is in the session route's page allowlist and a
page write over `PAGE_STORAGE_MAX_VALUE_CHARS` rejects rather than drops (the
size contract of 33.4, extended by 33.6 to a key `init` could not carry), so
past ~16 KB of accessory keys this surfaced as an unhandled rejection in the
page -- which the fault boundary reports and which drops the generation.
Every other allowlisted writer in this closure already catches: the two write
chains in `TerminalShortcutSettings`, the live-input save and the session-view
preference.
Caught at the boundary and logged, and the drawer neither announces the key
nor closes: a row on the accessory bar that no store holds, gone at the next
load, is the failure the allowlist exists to avoid. Red first -- the case saw
the refusal escape with the page's own message -- and the control reds again
when the catch rethrows.
Belongs in `8b4c559e90` by the brief; it is its own commit because that one
was already made and amending is forbidden.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): reject a batch of oversize writes once, not once per pair (OTA phase C, C7.7 round 2)
CodeRabbit and pullfrog, same site. `settleBatch` called `settle` per refusal
and kept the first rejected promise, so a `multiSet` or `multiRemove` with two
over-cap pairs built a second rejected promise nobody held -- an unhandled
rejection in the page, the outcome ruling 33.4's rejection scope exists to
avoid. Two oversize keys is all it takes, and `storageOversize` made a second
way to reach it.
A refusal is now an error or nothing, and only the caller's one rejection ever
becomes a promise. Red first under an `unhandledRejection` listener with two
over-cap pairs: one orphan before, none after, and the caller still hears
about the first key.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): record a route key only once a frame carried it (OTA phase C, C7.7 round 2)
CodeRabbit on `MobileWebShellScreen.tsx:271`. The effect recorded the route's
key and then published, so a publish the hook refused for having no host was
remembered as though it had gone out. `publishRoute` is now keyed on
everything the host is built from rather than on the session alone, so the
render that brings the host re-runs the effect, and the key is written only
after a frame has left.
Reported honestly: this does not repair a lost tap, and the case beside it
says so. The host is built from the route the render holds, so a route that
moved before it existed rides the first `init` either way and `publishRoute`
then answers "did not move". What the change removes is a key recorded for a
frame nobody sent -- the same contract finding 2 fixed on the `ready` path.
The case pins the delivery count across the gap: nothing reported while there
is no host, nothing reported once there is one and it has sent nothing, and
exactly one report when the `init` answering the page's ask carries the route.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse an oversize-dropped key at the shell, not only at the page (OTA phase C, C7.7 round 2)
pullfrog's rollout gap on ruling 33.6. `storageOversize` is honoured by a page
built with it, and the page is served from the desktop: a document from an
older bundle ignores the field and writes the key whole, which for the send
journal replaces every entry the device holds. The shell is the half that
updates with the app, so the shell is where the refusal has to live.
The host now refuses a `storage` notify for a key it could not hand the page,
answering it as the drop it already answers an unlisted key with. The page's
own rejection stays as the fast path -- it reaches the composer as
"Message not sent" with no round trip -- and the schema comment says the field
is advisory and the shell enforces it.
Red first: a host holding the journal as oversize received a page write for it
and posted it to native storage; now it posts nothing and the entries survive,
while a key it did hand over is still writable.
The three refusals became one predicate in `page-storage-keys.ts`, where the
keys are, because inlining the third put `bridge-host.ts` over its line cap.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): the page's pane hook gets its own test (OTA phase C, C7.7 round 2)
pullfrog: `use-notification-pane-navigation.web.ts` had no cover. The native
file's test mounts the native file, and `bridge-route-update.test.ts` stops at
the client, so the half that turns a route update into a tab switch was
untested.
Seven cases: the seed from the route the page was opened on, a request held
until the terminals load, a repeat tap on the pane already showing, a
different pane, the clear the shell posts after each delivery, a pane that has
since closed, and a page opened on no pane at all.
Two controls, so the cases are not all satisfied by one behaviour. Dropping
the seed reds the two that read the first `init`. Deduplicating by value
instead of counting deliveries reds the repeat tap, which is the case the
counter exists for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): roll the custom-keys mirror back when the store refuses (OTA phase C, C7.7 round 2)
CodeRabbit. `saveCustomKeys` notes the write in the mirror before it persists,
because a reader is answered from the map rather than from the store and the
shell builds `init` synchronously from that map. On a refused write the note
stood: the page's next `init` carried the value native had rejected, and every
native reader of the key saw it too.
The previous mirrored value is captured and put back on the failure path, and
the error still goes to the caller so `addKey` keeps withholding the key.
Red first: with the store refusing, the mirror held the rejected value where
the pre-save value belonged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): report a route delivered only after its frame was posted (OTA phase C, C7.7 round 2)
CodeRabbit on the send path. `sendInit` answered "sent" the moment it handed
the JSON to `post`, and a rejected post was reported a turn later as a
diagnostic -- so the screen spent the one-shot `paneKey` on a frame the page
never received, cleared the native param, and the tap was gone. `publishRoute`
was fire-and-forget the same way.
Delivery is a promise now, settled after `options.post` resolves and false on
either throw or reject. Readiness stays separate: `onPageReady` fires on the
ask, as the shell's wait needs, and carries the delivery promise beside it.
The screen records the route key and calls `onRouteDelivered` only when that
promise answers true, and a refusal leaves nothing recorded so the next render
that can carry the route tries again.
Red first: with the view refusing what it was handed, the frame was built and
posted and the screen reported delivery anyway. Now it reports none while the
page's ask is still reported, and a host-level case pins the same split.
`bridge-host.ts` was at 299 of 300 lines, so the send half came out as
`bridge-host-frames.ts` rather than growing it; the file now measures 280.
The screen's own test harness never attached the view handle, so every post in
it rejected unobserved -- it attaches one now, which is what let the case see
the frame at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): fold two doc blocks back onto what they describe (OTA phase C, C7.7 round 2)
pullfrog's two nits, no behaviour change. `page-async-storage.ts` kept the old
`settle` block above `refusalError` when the function it described moved down
with a one-liner of its own; the orphan goes. `bridge-host.ts` had two stacked
blocks on `sendInit` after it grew a return value; they are one, and it now
says the frame is still built synchronously and only the post is awaited --
which is the property the golden recorder depends on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let the host own a pending route until its frame lands
A route the page has not received is now the host's, not the screen's. `publish` keeps it
pending until a post resolves true, marks it delivered only then, and reports that through a
callback registered once per host. Movement is measured against what a frame actually reached
the page with rather than against what the host holds, so a refused frame leaves the route owed
instead of reading as one that did not move.
Three things the old shape lost, each a case here: a frame the view refused was never retried,
because only another render could try and a mounted page has none coming; a render while a post
was in flight cancelled the report the switch spends to clear the param; and a repeat tap for
the same pane was held, because the host had already moved its held route on the attempt that
failed. The retries are the moments delivery becomes possible again — the next `ready`, and a
view handle the host regains — and one frame goes out at a time.
Also folds round 4's doc nits: the stale delivery comment the screen no longer has a ref for,
and a leftover `an` in the `ready` branch.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): roll the journal mirror back when persistence fails
`writeEntries` noted the mirror before the store took it, which is what keeps an `init` built in
the same turn current — but it kept the note when the store refused. The page then received a
journal the device never wrote and resumed operations nothing was holding.
Restored on the error path, the same shape as the custom-keys save, and on both halves: the
removal that empties the journal had the same gap as the write that fills it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep publishing until the held route is the delivered one
Two halves of the same gap, both found by the bots on `fba8cb3f3d`.
A route that moved while a frame was out was held for the turn and then had nothing to wake it:
the post settling only cleared the in-flight flag, and on a mounted page no `ready`, handle or
tap need ever come along. A landing is now itself a moment to publish again, while what the host
holds is not what the page has. Only on a landing — a refused post that re-attempted itself
would spin, and that one still waits for whatever makes delivery possible again.
And the report carries the route a frame reached the page with, which the session switch was
ignoring: the older pane landing wiped the `paneKey` naming the newer one, so the page stayed
where it was and the second tap was gone. The switch now spends the param only for the pane that
was delivered.
The delivery cases render through one helper rather than six copies of the same setup, which is
what keeps the file under its cap.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): drop the second block describing a parameter onPageReady no longer takes
The field is documented by the block above it; this one still described the `delivered` promise
the handler was handed before the host took ownership of the pending route.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): let the page erase the route param it was handed
Ruling 34, step one: one page-to-shell frame that asks the shell to clear a one-shot route param,
naming the value the page applied.
Closed at both ends. The param is an enum of what the shell hands over, so a page cannot edit a
route it was never given; the notify name is a member of the closed union, so it gets a row in
the grant table by compilation rather than by memory, and rides no grant because it can only
spend something this shell put there. The shell declares it in `init`, the mirror of
`ready.accepts`: no shipped shell serves a page, so nothing needs negotiating today and the
page's check exists from the first version that can post one.
The comparison belongs to whoever holds the param, which is the session switch: a tap that moved
on while the page was applying the one before it leaves a newer key, and a clear naming the older
one is not for it.
`bridge-envelope.ts` went over its cap, so the page-to-shell union moved to
`bridge-notify-envelope.ts` and the fields both halves spell to `bridge-frame-fields.ts`, which
the envelope re-exports. No cap was raised.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): re-send init on a route change and track nothing else
Ruling 34, step two: the tracked handoff is gone. Deleted, not patched — the pending route, the
delivered route, the in-flight flag, the landing callback, `retryPendingRoute`, the delivery
promise `onPageReady` used to carry, and the `onRouteDelivered` that ran from the host through
the hook and the screen to the switch.
What is left is the rule in one line: `publish` sends one `init` when the route moved and the
page said it takes one, and every `ready` is answered with the route the shell holds then. A
frame the view refused is repaired by the next ask, not by a retry; the request it carried is
spent by the page.
The cases that tested the deleted mechanism go with it. The outcomes they protected are pinned
where they now live: one frame per move and none for a render that moved nothing, a lost frame
repaired by the next ask, no second `init` to a page that never said it takes one, and the
repeat tap measured through the page's erase rather than through a delivery report.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): the page applies a pane and erases the request that carried it
Ruling 34, step three. The page hook applies the pane an `init` names and asks the shell to erase
the param it came on, naming what it applied.
Two rules, and both are the page's because the shell has none. The erase is asked for on every
`init` that carries a pane rather than only on the one that changed something: a clear that never
reached the shell leaves the param in place, and the next frame carrying it is the repair. The
switch happens once per value: a re-asked `ready` is answered with the route the shell still
holds, and applying that again would drag the page off a tab the user has since moved to.
A repeat tap for the same pane still arrives as a request, because the erase went through in
between and the tap wrote the param back. The client refuses to post the frame to a shell that
did not declare it takes one, which is every shell older than the field.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep the page bundle's readers pointed at the module that declares each name
The envelope split left three source-text readers and one import pointed at a file that now
re-exports what they read.
`shell-screen-route.ts` read the route schema back through the envelope, which reaches that file
again through the page-to-shell union: a cycle esbuild resolves to `undefined`, so every page
route mounted onto a schema that was not there yet and the browser render suite failed on twelve
files with a TypeError rather than on a build error. It reads the declaring module now.
The render harness read `BRIDGE_PROTOCOL_VERSION` and `BRIDGE_FAULT_GRANT` out of the envelope by
regex; both moved, and a regex over a re-export answers for whichever file the last split left
them in. Both point at `bridge-frame-fields.ts`, and the throw names it.
The session route's page closure is re-measured on this tree at 4,330 / 988 and the four new
modules are named, not inferred: the two halves of the split envelope, and the route-update
module and route-key reader the page-to-shell union now reaches through it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count posted inits with the page's own reader, not a cast
The changed-code quality gate refuses a type assertion, and it is right to here: a frame the
page's reader would refuse is not an `init` the page ever saw, so a case counting them must not
count one either.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): put every mirrored write on one path that notes what the store took
Ruling 35. Fourteen call sites in six files noted the shell's mirror before persisting, and on
the page a persist can be refused: twelve left the map holding a value no store had taken, and
the next `init` handed the page exactly that. Two undid it by hand.
`persistMirrored` is the one path now, and it seats the map from what the store holds after the
write rather than from what it was handed. That is what makes the note follow acceptance without
a second opinion about it: the page's adapter resolves a `not-allowed` write and logs it, so a
rejection is not the only refusal there is, and reading back is the only answer that covers both.
The cost is one store read per mirrored write on the device, where the store refuses nothing.
`writeMirroredStorage` keeps its note-then-persist order and loses every caller but one: the
shell taking a value the page has already applied, into the device store, which has no allowlist
and no frame cap to refuse against. It builds the next `init` synchronously in the same turn, so
noting on the store's reply there would hand the page back the value it just changed. The
last-visited key moved off it, because that module is in the page's own closure.
Both rollbacks are gone with the notes that needed them, and `noteMirroredWrite` is private. A
source-scanning census holds each writer to the path by name.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): answer a page batch write at the first pair it cannot take
Ruling 35's other half. `settleBatch` collected a refusal per pair, logged each, and rejected
with the first that could reject while the rest of the batch went in anyway — one promise
describing a call where some pairs landed and some did not, which is not something a caller can
act on.
A batch is one call with one answer now: every pair before the refusal is applied, the refusal is
the answer, and nothing after it is attempted. No page-closure writer calls `multiSet` or
`multiRemove` today, so this is the rule for whoever writes the first one rather than a change to
anyone's behaviour; both directions are pinned, including the refused first pair that stops the
rest.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the mirrored write path's census and journal writer
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count the note-first callers the mirror module says are held to one
Two gaps pullfrog found in the census. The last-visited key moved onto `persistMirrored` with no
row naming it, so removing its write path reddened nothing; and `mirrored-storage-keys.ts` says
the census holds `writeMirroredStorage` to one caller while nothing counted them.
Counted now, over every module under `mobile/src` rather than over a list of files a new caller
could sit outside of: a second one is either a writer that wants note-then-persist without the
store that earns it, or a page-reachable module that would note a refusal as an accepted write.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): fold sendInit's doc onto the function it now describes
It still described an awaited post that answered whether the page received the frame, which
ruling 34 deleted: it fires the frame and answers nothing, a refused route sends nothing at all,
and a post the view would not take is one diagnostic and no further attempt.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let the page own a frame it received and could not handle
Ruling 34's addendum. On iOS the host's post is `callAsyncJavaScript`, which rejects when the
page's synchronous `onmessage` throws — with the document still mounted. The shell reads that as
a frame that never arrived, and it tracks nothing about posts, so nothing would ever send it
again. It is not a lost frame either: the page had it, one of its own listeners failed, and a
retry would fail the same way.
`receive` catches it and reports `inbound-listener-threw`, so the delivery is the channel's and
the handling is the page's. Nothing is swallowed and nothing is retried.
Two cases pinned the throw escaping and now pin it being reported: the ack that a listener bug
must not wedge, and the bootstrap stamp a tree that throws still leaves behind.
With that path closed, a post is refused only when no document holds the view, and the comments
on both halves of the route seam say so instead of naming a backoff that is stopped by then. The
repair is pinned rather than described: a tap that arrives while the view is gone is carried to
the next document's `ready`, because the held route advances on `hold` as well as on `send`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): move the page's held session out of the client, which was at its cap
The listener catch put `bridge-rpc-client.ts` at 305 counted lines against a cap of 300, so this
splits rather than bumps.
The session is the one piece of the client with a lifecycle rather than a value: a second `init`
for the same session updates it in place, a different one replaces it and takes the requests and
streams of the session before it, and each case has its own listeners to fire in its own order.
The client keeps the frames and the ports; `bridge-client-shell-session.ts` keeps what they are
for, and the client's three members delegate to it.
The client measures 277 counted lines after the move. The session route's page closure is
unchanged at 4,330 / 988: the page reaches its client from the entry, not from the route module.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(native-chat): light the unread indicators when a structured chat finishes
A structured native chat had no attention producer. The PTY lane reaches the
unread markers through use-notification-dispatch, whose liveness reads PTY
state and whose admission requires terminal panes, so a structured session —
which runs on the execution host with no renderer PTY — could finish a turn
with nothing lighting anywhere. A backgrounded chat was the worst case: with
no mounted pane there was no reader to notice at all.
The host derives the completion, because only the host can. The journal keeps
committing whether or not a renderer holds a reader, so the new feed observes
each commit at StructuredAgentSessionClientDelivery.publishJournal and emits on
every running -> settled transition. That edge runs after the subscriber loop
and independent of it, which is exactly why a chat nobody is watching can still
complete. It is a separate capability-gated stream rather than a field on the
status summary: the summary carries no turn identity and no outcome, and is
re-broadcast on every status change, so folding a completion into it would make
every status consumer a completion consumer.
ONLY `success` LIGHTS ANYTHING. Outcome is A0's provider verdict and is never
inferred: a turn the host merely watched stop carries no outcome and produces
no event, because absent means UNKNOWN. `completed` alone proves nothing — a
provider reports its own API error as a finished turn — so the host emits
nothing for it and the renderer filters again on the way in.
RECOVERY IS LIVE-ONLY. Nothing is retained, queued or replayed on either side.
A subscriber learns what settles while it is subscribed and nothing else; on
reconnect it re-opens an empty stream and whatever landed during the gap is
gone. A retained completion would be a durable "unread is owed" obligation with
nothing to retire it, and a reconnect would then light the dot for work the
user already read. Tests on both sides pin this so a later refactor cannot
quietly turn it into catch-up.
The dot itself reuses the neutral policy in attention/agent-attention-policy
and #21274's structured surface adapter, so suppression, acknowledgement and
addressing keep exactly one implementation and the surface key is never omitted
to evade a check. No second suppression rule is introduced. OS delivery is
deliberately not wired: this calls applyAgentAttentionUnread, not
applyAgentAttention.
Also narrows the completion feed's journal dependency to the newest-turn reader
it actually uses, and adds journal.newestTurn() beside the existing
activeTurnId() on the one shared by-sequence scan rather than a second scan.
* test(cross-version): register the turn-completion subscribe on the wire manifest
The cross-version gate asserts the structured surface's method list by name and
count, so an additive method has to be declared there deliberately. Adding the
entry makes the suite call it in both skew directions and stubs the host side,
which is the statement the gate exists to force.
* fix(native-chat): rebaseline completion feed after rewinds
* fix(native-chat): end a structured turn when the provider reports it failed (#22044)
A turn reads as working while its durable turn row says `running`, and only two
events could write a terminal row: the provider's turn-completed notification and
the provider process going away. A provider error that ends a turn is neither, so
the row stayed `running` with nothing re-deriving it, and the chat counted
"Working for N" for the life of the session.
Codex reports such a failure as an `error` notification naming the turn it ended,
with `willRetry` distinguishing it from a stream error it is about to retry. That
frame now settles the turn it names. Claude's CLI reports the same through its
session-state frame, whose `idle` arm the SDK documents as the authoritative
turn-over signal; that now settles the open turn too.
Codex's `thread/status/changed` deliberately settles no open turn: the app server
clears `running` on every error, including ones it reports as not affecting turn
status, so a turn still open there is still running. What it does settle is a send
whose dispatch was never answered — a timed-out dispatch is recorded as unverified
delivery, reads as work still owed, and nothing in a live session retired it.
Retiring it never makes the send re-deliverable.
Splits the codex notification translator so the file stays inside its line budget.
* fix(codex): defer idle dispatch release until turn end
* fix(claude): enable session state lifecycle events
Implement windowing for the conflict review file tree using
SourceControlVirtualFileList to efficiently handle large merge conflicts.
Add comprehensive tests for virtualization behavior including scrolling,
collapsing, and dynamic updates.
* fix(native-chat): collapse a finished turn to its answer
A finished turn's "Worked for N" row hid the turn's tool runs and nothing
else. Every sentence the agent said on the way to its answer stayed in the
transcript, so the resting state of a long chat was the narration, not the
reply — one 16m 56s review turn left 21 assistant messages and roughly
seven screens of scrolling behind a control that reads as if it had put
the work away.
The fold's unit is now the turn. A settled turn draws its prompt, its
duration, and its answer; the narration and activity that produced it sit
behind the caret. The answer is the turn's last assistant row that renders
prose — derived, because the journal carries no marker saying which message
is the reply.
Collapsed stays derived rather than stored: nothing closes the disclosure
when a turn ends, it arrives closed because the turn gained a duration. A
running turn therefore folds nothing and the reader watches the work as it
happens, which is what already happened and is now stated rather than
inherited.
Rows that outlive the turn that started them stay outside the fold — a
spawn roster and a background task are often the only record of how that
work ended. So does the reader's own message, question receipts, and the
turn's diff rollup. A turn that produced no prose folds whole, its status
row standing as the anchor.
Two presentation changes come with it, both about the opened view:
- A settled run's header was a call count followed by a monospace list of
tool names and arguments. It is now one sentence in the transcript's own
type — "Read 7 files, ran 17 commands, and searched 4 times" — built on
the tool-category vocabulary that already picks the row's glyph, so the
words and the icon cannot claim different things. A run of one command
keeps that command as its header.
- A tool call now owns its result instead of standing beside a separate
`Result` row, so an opened run lists the work rather than twice as many
rows half of which say `Result`. Output is one more click. Pairing is
positional — a result answers the most recent unanswered call — because
result blocks carry no call identifier to match on.
Command previews also lose the `/bin/zsh -lc "…"` wrapper they all opened
with. The unwrap happens inside `summarizeToolInput`, before truncation,
because the clip at 80 characters removes the closing quote that proves the
wrapper; one site fixes the header, the rows, and the running label.
Measured on a real session journal at 1200x900: the turn above goes from
6,300px across 51 rows to 452px across 2, the whole session from 8,151px
to 2,138px, and the same turn opened from 18,540px to 11,131px.
The fold derivation lives in `src/shared` so the mobile transcript can read
the same rule; wiring mobile's list to it is not part of this change.
* fix(native-chat): preserve FIFO tool result pairing
* fix(native-chat): keep tools collapsed when opening turn
* test(native-chat): clarify independent tool disclosures
* fix(updater): send the gh token and cache the release picker's build list
The dev build picker listed releases through api.github.com with no
Authorization header, so it spent GitHub's 60/hour per-IP bucket that every
unauthenticated caller on the same network shares, and it refetched on every
settings mount and channel click. When that bucket ran dry the picker showed
"No builds found" with a rate-limit line even though GitHub was healthy and
the user's own token had its full quota.
Attach the local `gh auth token` when there is one so the request draws from
the user's 5000/hour bucket, fall back to unauthenticated on a rejected token
or a spent token bucket, cache the list per channel for five minutes in the
main process (the refresh button forces a reload), classify 403 by the
rate-limit headers, and say when the limit resets.
Fixes#21898
* fix(updater): don't trip breaker for secondary rate limits
GitHub sends x-ratelimit-remaining: 0 on both primary and secondary
limits. Secondary limits carry Retry-After and shouldn't block all core
gh commands — only the primary limit should trip the shared breaker.
* Scope gh rate limits to execution environment
* Add build list cache hint to release channel settings
Inform users that build lists are cached for 5 minutes and they can
refresh to check for new builds immediately. This makes the cache
behavior visible and explains why a manual refresh is necessary to
bypass the cache.
* docs(attr-parent-label): record the attribution defect and its constraints
* docs(attr-parent-label): add reference findings and the feasibility fact
* docs(attr-parent-label): verify at source and decide the attribution mechanism
Re-baselined against origin/main (one unrelated commit; no drift in any cited
file). Confirmed the two unverified items at source, found a third reader with
the same defect and a fourth append path a naive fix would miss, and recorded
the producer-attribution decision with its field shape, migration behaviour,
wire category, tests and implementation order.
* fix(native-chat): stop a subagent's output speaking for the agent that spawned it
One journal is the durable record of one agent session, but a session that runs
subagents journals their rows into it too, with nothing on the row saying which
agent wrote it. Every "what is this agent doing right now" reader is a backward
scan bounded by markers only the root agent writes, so the window is guaranteed
to hold foreign rows and, while a subagent runs, the newest row in it is the
child's. The sidebar therefore showed a child's prose and a child's running tool
on the parent's row.
Attribute at the producer instead of guessing at the reader. The Claude
translator already parses `parent_tool_use_id` on every envelope and threw it
away; it now stamps `producedBySubagent` on every row that envelope produces,
including the streamed-text path, which persists from a callback with no
envelope in scope and takes the flag from the block identity registry that
already scopes itself on that id. The three status readers skip non-root rows
through one shared predicate. The transcript is deliberately left unscoped: it
shows every agent's output.
No schema version bump, no upcaster, no backfill. An unknown `v` makes a row
unreadable and latches the host read-only, while an unknown key is ignored, so
an older host reads a stamped row and behaves exactly as it does today. Rows
written before the flag read as root, which reproduces today's behaviour for
that history exactly.
* docs(attr-parent-label): add the PR body for the producer-attribution change
* style(native-chat): apply formatter to the merge resolution
* fix(native-chat): preserve producer attribution in resolved appends
* chore: keep attribution review artifacts under docs
* chore: remove review artifacts
* fix(mobile): a checkbox tap in the rich editor reports one change
A tap raises click, input and change, all three bubble to `#editor`, and each
handler emitted: three identical changes under one generation. The tick and the
change now come from `change` alone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): inline code over a selection reports one change
`wrapSelection` emitted and `runCommand` emits after every command, so the one
command that wraps rather than execs reported twice. The wrap helper now emits
nothing; `inlineCode` is its only caller.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): a rich-editor code block can hold a backtick fence
A fixed three-backtick fence ends at the first three-backtick run inside it, so
a block containing a fence rendered as paragraphs. The writer now measures the
longest run and opens one longer; the reader carries the run it opened with and
closes only on one at least as long.
The bundle's input count moves by the one module the two halves now share.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): a rich-editor table cell can hold a pipe
The reader split on every pipe and the writer joined without escaping, so a cell
containing a pipe became two columns and the backslash that hid it survived as
text. The reader now splits on unescaped pipes and undoes the escapes; the
writer escapes backslashes before pipes, which is the order that round-trips a
cell holding both.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep the checkbox input's caret-flag clear
The checkbox fix skipped the whole input handler, which also stopped clearing
`selectionDroppedOnBlur`. That flag is not part of the duplicate-change defect,
so it is cleared as it always was and only the emit moves to `change`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The sweep's capture waited two animation frames after painting the noise
and then measured the next screencast frame. That is a main-thread
commit, not a raster: the screencast hands over whatever the compositor
has drawn so far, so after a resize it emits frames at the full size
carrying only the tiles rastered yet.
Measured under CPU starvation at 1400x1600, 87 of 444 post-commit frames
read under the noise floor, one of them 447491 bytes where the full
frame is 1221117. A frame that short posts inside the 640 KiB cap, which
is what failed "the shell drops what will not fit" with `expected 596462
to be null` on a lead gate.
`Page.captureScreenshot` returns only once a compositor frame of the
current content exists, so it is the raster the measurement needs. It is
ordered on the same CDP session, so every frame counted after it is one
the compositor had finished. Over the same rounds with it, none read
under the floor.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
#21844 removed the Codex launch-argument attention suppressor, which was the
last production caller of two launch-config lookups. Both were left in place
for a follow-up; this is it.
`getAgentLaunchConfigForStatusMetadata` (renderer store) looked a launch config
up from a loose metadata bag. Its sibling `getAgentLaunchConfigForStatusEntry`
takes a real status entry and still serves the one live consumer, cold restore
resume startup. Deleting the metadata accessor also orphaned its
`getLaunchConfigForStatusMetadata` helper and the
`AgentLaunchConfigStatusMetadata` parameter type, so those go too.
`getAgentStatusLaunchConfigForPaneKey` (main runtime) returned a pane's launch
config behind a launch-token fence. Its two remaining references were
assertions in the launch-authority retirement test. They were a second view of
a state bit the test already pins: retirement nulls `pty.launchToken`, and the
surviving `verifyOrchestrationCompatibilityCaller` assertion fails when it
does not. Verified by ablation — disabling only the `launchToken` nulling
fails that assertion with the accessor already gone, so no coverage is lost.
Retirement never cleared `launchConfig` itself, so there was no second
property hiding in those assertions.
Test mocks that existed only to satisfy the removed store method are stripped;
the tests themselves are about other behaviour and stay.
No behaviour change.
* feat(session-search): sort search results by newest, and break relevance ties by recency
Results were ordered by match score alone with the session id as the
tiebreak, so equally good matches came out in an arbitrary order and
nothing ever favoured recent work. The Sort menu now offers Most relevant
and Newest while the box has text; the engine already knew both orders and
the all-computers merge already honoured the newest one, so only the panel
had to ask. Under Most relevant, equal scores now go to the newer session.
The choice persists with the other view options, separately from the
list's own Last updated / Created sort.
* fix(session-search): label results by the order they are in
The header subtitle and the results group said "best matches" whichever
sort was chosen; under Newest they now say so. The panel's scope state and
its two context effects move to use-ai-vault-panel-scope.ts, which keeps
the panel under the line cap and gives that behaviour a name.
* feat(session-search): move search sort onto a results bar above the hits
Search mode gets a bar in the group header's place: the hit count on the
left, a ghost menu button on the right that names the current order and
opens the two-item radio group. The filter menu's Sort section keeps one
meaning again (Last updated / Created), the header subtitle stops
reporting sort, and search rows run flat with no group header.
* style(session-search): drop the icons from the results-bar sort menu and match its text size
* feat(session-search): one sort bar above the list in both modes
Filters stay behind the header filter icon; sort moves onto the bar
directly above the session list, in browse mode as well as search.
The bar is mode-agnostic: it takes a label, the selected value, a typed
option list, and a callback, and the panel configures it twice.
- rename AiVaultSearchResultsBar to AiVaultSessionListBar and generalize it
- add ai-vault-sort-options for the two option lists and their aria labels
- drop the Sort section from the filter menu and stop counting sort in the badge
- header subtitle now reads "Indexed history" in both modes
* feat(session-search): count sessions plainly and offer Show more when the scan fills its depth
* fix(session-search): step history depth 250 at a time and keep Show more visible while the rescan runs
* style(session-search): let the sort menu hug its two options
* fix(session-search): show more reads the depth its rows came from
The row inferred "a deeper rescan is running" from the selected depth minus one
page, which at the default depth is zero, so every foreground scan with at least
one session painted a disabled "Loading more sessions…" footer the scan had room
for.
The scan now publishes the depth it ran at beside its sessions, and the row
compares the two: it survives the rescan because that depth trails the selected
one until the deeper scan lands. Drops the stepping arithmetic and
nextAiVaultSessionLimit, and moves the row out of the menu file it was sharing.
* refactor(session-search): an untitled group is what hides a header
Search mode said "no group headers" twice, in two files, both keyed off the same
flag: an empty label in the filters hook and a hideGroupHeaders prop on the list.
The label is now the only fact. A null label means the group has no header of
its own, the list renders its rows flat, and the prop is gone.
The shared group type keeps its string label so the mobile sections that map it
are untouched; the nullable label is the renderer list's own type.
* refactor(session-search): plain labels, and a browse bar that can report zero
Three small simplifications around the list bar:
- The browse bar is guarded on the loaded history rather than the filtered rows,
so "0 of 250 sessions" can actually appear when filters hide everything and
the sort control stays reachable. Search keeps its own guard.
- The two count labels were components whose whole body was a ternary over
translate; they are functions returning a string, and the bar's label prop is
a string.
- The persistence guards stop being exported with no caller outside the file,
and the search-sort guard reads the AI_VAULT_SEARCH_SORTS list instead of
respelling the union.
`agent.launch` could hand its initial text to a structured session but not to
a terminal. The contract already anticipated the terminal half — the
`handed-to-terminal` arm has been declared in agent-launch-intent.ts since the
receipt was written and had zero producers — and the executor's own docstring
recorded the assumption behind the gap: that a terminal's paste belongs to the
pane owner. That assumption is what this overturns. The host owns the PTY, so
it can write into one whether or not any window is open on it, which is why
mobile and the CLI got an agent and no prompt.
A terminal takes its prompt one of two ways, and which one is not a
preference. `argv` exists so multi-line and special-character text reaches a
CLI as one argument rather than keystrokes, and it has no readiness race
because the text is in the process's arguments at exec time. So an agent whose
CLI accepts a prompt argument gets it on the launch command, and only a
`stdin-after-start` agent — plus any reused terminal, whose process started
before the launch existed — is written to as a bracketed paste.
That fork is asked once. `agentPromptRidesLaunchCommand` is derived from the
same injection table `buildAgentStartupPlan` branches on, and
tui-agent-prompt-transport.test.ts pins the two against each other for all 37
agents, so adding an agent or changing its mode fails loudly instead of
silently dropping that agent's prompt.
Reused rather than rebuilt: `sendTerminalAgentPrompt` is the runtime's one
agent-prompt writer (bracketed paste, per-PTY serialization, lifecycle
generation pinning, per-agent submit timing, and local/WSL/SSH routing), gated
by `waitForTerminal('tui-idle')` — the same pair orchestration's worker
dispatch already delivers a preamble through. The agent-first create path
needed no new mechanism at all: `startupPrompt` already flows to
`buildWorktreeStartupForAgent`, and the launch had simply been stripping it as
a reserved field without re-supplying its own.
Receipts stay consequences of the act they name. `handed-to-terminal` is
reported only from a launch command that carried the text or a PTY write that
returned; everything unproven under-claims as `not-delivered`. No fourth arm.
The one inversion is a stalled submission, which the verifier raises after the
write: that is reported as delivered, because a resend would paste the whole
prompt a second time into an agent already working on it.
A prompt the launch command cannot carry is refused at the terminal-create
resolver rather than dropped, since that path returns options and has no PTY
to fall back to.
`delivery: 'draft'` remains `not-delivered` for both surfaces. The host could
paste a terminal draft without submitting it, but it cannot observe that the
composer accepted it, so a receipt claiming delivery would be a guess.
No call site is migrated, nothing is added to the wire, and placement and tab
creation are untouched.
* fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27)
Native markdown and the native rich editor load images the author referenced
by URL, so the page has to as well or a remote image is a blank where native
paints a picture. `img-src` widens to `img-src 'self' data: https:` on both
platforms; `script-src`, `connect-src`, `object-src`, `frame-src` and
`child-src` do not move.
`http:` stays out, and the pins say so directly rather than by absence: the
Kotlin test's blanket `!contains("http")` could not survive `https:`, so both
native pins now check `http:` (not a substring of `https:`) and check that
`https:` appears in `img-src` and nowhere else, the same shape the `data:`
pin already had.
No behaviour change on released phones: the shell ships in no released tag
(mobile-v0.0.9 predates it), so this reaches devices with the Phase E native
build and not before.
Neither native module has a CI job, so both ran locally: swiftc over the
module plus MobileWebShellChecks, and
`:orca-mobile-web-shell:testDebugUnitTest`. Both were confirmed red against
the old directive first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct what the sealed preview frame is stricter about
The doc comment said the page was deliberately stricter than the native
preview because it loads no remote image and runs no script. Since `img-src`
gained `https:` only the script half is true: the frame loads a remote image
exactly as the native WebView does.
Says instead what an artifact's image URL now is -- a channel that fires on
view and carries whatever its author encoded, with nothing dynamic behind it
because no script runs -- and names `referrerPolicy` as what keeps the
document's own origin out of the request.
Comment only; no behaviour and no test moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): measure both halves of the preview frame's image fence
"fetches nothing of the artifact that leaves the origin" stopped being what
the sealed arm proves once `img-src` gained `https:`. The fixture's foreign
origin is `http://127.0.0.1`, so its two images are refused on the scheme
alone and only the font is refused by `font-src 'none'`. Renamed to say
exactly that.
The half that was missing is an https arm. Playwright route interception
answers an `https://…invalid` origin in the page, so the arm needs no TLS
server and no new dependency, and a request only reaches the handler if the
policy let it out. Under the shipped header, on Chromium and WebKit, the
`<img>` and the CSS background are both requested -- `img-src` governs a
background too -- and the font still is not.
`artifact()` takes the subresource origin; the links stay on the cleartext
one so no existing navigation case changes.
Red-first: with `img-src 'self' data:` put back into the parsed Kotlin
policy, the new arm fails on both engines with `expected [] to deeply equal
[ '/css-bg.png', '/img.png' ]`. The directive was restored byte-identical
before this commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(scripts): split the preview frame's settling out of the render check
The https arm pushed mobile-web-app-html-preview-render.test.mjs to 620
counted lines, over the 600 cap config/scripts carries. Split at a module
boundary rather than bumped: the four wait-and-settle functions are rig
mechanics with no assertion in them, and they now sit beside the diagnosis
module they already reported through.
`waitForLoadedFrame` and `settleAfterMount` are the two the render check
calls; `waitForRecordedNavigation` and `settleWithoutNavigation` stay
internal to the new module.
Move only. Same 20 tests pass on both engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): send Referrer-Policy: no-referrer on the shell document
`img-src https:` gave the page somewhere to send a request, and the document
origin is `orca-mobile-web://<sessionId>/`, so a request that carries a
referrer carries the session id to whatever host an artifact or a markdown
document named.
`referrerPolicy="no-referrer"` on the preview iframe does not cover it.
Measured in the render rig against a permissive control policy: WebKit puts
the embedder's URL on a srcdoc frame's image request despite the attribute,
and Chromium sends none. So the guarantee belongs on the document, where one
header covers every request the page makes, and it rides the document alone
with the policy -- the referrer of a request is decided by the document that
made it, so on a subresource response it would govern nothing.
WKWebView under the custom scheme is unverified: the rig is Playwright
WebKit over http, not WKWebView over `orca-mobile-web://`. The header is the
hedge, and it costs nothing if that host never leaked.
Pinned three ways, each confirmed red first:
- Swift, exit 133 with the header removed.
- Kotlin, MobileWebShellResponseHeadersTest "sends the policy on the
document" FAILED at :17 with it removed.
- The rig, through a new `readShellDocumentHeaders` that parses the Kotlin
source the way `readShellCsp` does and throws rather than returning an
empty map. With the value flipped to `unsafe-url` the WebKit arm fails
`expected [ …(2) ] to deeply equal [ null, null ]`; with the line deleted
the parse throws "could not parse the shell document headers".
The rig's arm carries its own presence precondition: a third server serves
the shipped policy with `unsafe-url`, so the WebKit reading is the header
doing the work, and Chromium's null either way is pinned as the browser's
behaviour rather than sold as evidence the header arrived.
MobileHtmlPreview.web.tsx said the iframe attribute kept the origin out of
the request. Corrected to name the header, since the measurement above is
what disproved it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): quote the current directive where the old text was written down
Three comments still read `img-src 'self' data:`, so a grep for the old
directive found live prose that no longer matches the header. Each stays
about `data:`, which is what those paths rest on; only the quoted policy
changes.
The two remaining hits in the repo are src/main/browser/doc-preview-protocol,
which is the desktop preview's own policy and not this one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the surfaces img-src https: actually unblocks today
The comment justified `https:` with markdown and the rich editor, and
neither renders a remote image on the page. Verified in the tree:
MobileMarkdown paints `` as a tappable link at both of its image
branches and never mounts an Image, and it has no `.web` sibling, so that is
what native does too; MobileRichMarkdownEditor.web.tsx is a 92-line
multiline TextInput, still C7.6's plain source field.
What the directive unblocks today is four surfaces, none of them overridden
on the page:
- MobileAgentIcon's favicon, a hardcoded `google.com/s2/favicons` URL, used
by thirteen callers including the session header and the worktree rows;
- MobileRepoIcon's project icon, a host-named favicon, avatar or upload, on
the worktree list and the host workspace list;
- PRCommentCard's author avatar, from the review reply schema;
- the sealed HTML preview frame, which inherits the policy.
Markdown and the editor are named as the anticipated surfaces ruling 26
points at, so a later reader does not take the loosening as already covering
them. Both native pins carried the same wrong claim and are corrected.
That comment is the only record of why the policy loosened, so it says what
is true now and what is coming, separately.
Comment only: the parsed header is unchanged, checked through the harness
reader the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): point the new source-control route pin at the current directive
Merge resolution, not a conflict git could see. #21957 landed the
source-control and review page routes on main while this branch was open,
and its render check pins the directive text twice: `cspHeader` by substring,
which survives the widening, and the Swift source by the quoted literal
`"img-src 'self' data:"`, which does not. Two PRs green alone, red on the
merge.
Both pins now read the current directive.
One comment goes with it. "Not one request left the origin, so there is
nothing for the policy to have refused" now needs saying why: `https:` is
admitted, so an empty host list is these two closures fetching nothing
rather than the policy refusing something. The avatar that would fetch needs
provider data this page never gets, which the file's own closing note
already explains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): wait for the admitted images before reading their hits
CI's Chrome 152 recorded the CSS background and not the `<img>` by the time
the bounded settle returned, so both https arms failed on a count: "expected
[ '/css-bg.png' ] to deeply equal [ '/css-bg.png', '/img.png' ]" and
"expected 1 to be 2". The reads were absence-shaped -- two frames and 200 ms
-- and the claim they carry is a presence.
So the arms wait for their own evidence, the way the `'refusal'` arm already
does. `frameReady: 'images'` polls until both admitted paths are recorded,
bounded by nothing but the case's own `ctx.signal`. It sits after the marker
wait, because an image is requested by a document that has parsed, and the
arm hands its reader in rather than the settling module reaching for state
that belongs to an arm.
One reader now serves the wait and the reading. An arm that waits on one
list and asserts on another has proved nothing about the list it asserts on.
The `/probe.woff2` absence is untouched and is now an absence standing
behind two presences rather than beside them.
What the wait prints when it does not arrive, captured by making the paths
unsatisfiable against a 12 s case:
[html-preview-render] the arm recorded ["/img.png","/css-bg.png"] of
["/css-bg.png","/img.png","/never-arrives.png"]; #remote
{"complete":true,"naturalWidth":1,
"currentSrc":"https://artifact-images.invalid/img.png?n=n1",
"loading":null}: arm csp=shipped sandbox=product frameReady=images
nonce=n1 | browser 147.0.7727.15 | ... | frames [...]
`complete` with a zero `naturalWidth` is a request that finished and
produced no image; `complete` false is one still in flight. So a Chrome that
never issues the request says which of those it was, instead of a bare count.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): say why an admitted image never arrived, and hand back the context
CI's Chrome 152 read the `<img>` as complete with a zero naturalWidth and a
resolved currentSrc while the route handler never saw the request, and the
CSS background from the same origin did reach it. The diagnosis could say
the image failed but not why, because nothing was watching the request.
Now four sources are, for the `.invalid` origin only, in a module of their
own so the rig file stays under its cap: `request` says whether the page
asked at all, `requestfailed` carries the browser's `errorText`, and CDP's
`Network.loadingFailed` adds `blockedReason` and `corsErrorStatus`, which is
the only place a refusal names itself once the request never reaches a route
handler. `Network.requestWillBeSent` records the resource type, the initiator
and the frame, which separates an image the parser found from one nothing
asked for. They fill arrays while an arm passes and are only read on abort.
Proved by forcing the abort rather than assuming: with the awaited paths made
unsatisfiable, the reading names the font's refusal in both vocabularies at
once, `failed [{"url":".../probe.woff2","errorText":"csp"}]` and `cdp
loadingFailed [{"errorText":"","blockedReason":"csp",...,"type":"Font"}]`,
beside `cdp sent` showing every request's type, initiator and frameId.
Teardown: `open()` now takes an explicit context and closes both the page and
the context in a `finally`. The close used to sit on the happy path, so an
arm whose wait aborted and whose result reads then raced vitest's teardown
left its page and its implicit context open on a browser every later case in
that engine still runs on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct three rationales the widening left wrong
(a) A review comment's avatar is not a surface the widening unblocks.
PRCommentCard renders it only under `Platform.OS !== 'web'` and a component
test pins the skip, so on the page it never renders. Dropped from both native
rationales and moved to the anticipated list beside markdown and the editor,
with the reason each is anticipated rather than current.
(b) The Kotlin rationale quoted the iOS origin. Android serves from
`https://<sha256(sessionId) first 32 hex>.orca-mobile-web.invalid/`, so a
referrer there carries a stable per-session handle and not the id itself,
while iOS serves `orca-mobile-web://<sessionId>/` and carries it verbatim.
Both are something an image host can key on across requests, which is what
the header is for; each file now names its own origin.
(c) "Only the script half of that is stricter than native" overstated it.
`font-src 'none'` and `connect-src 'self'` are stricter too. Images are the
one of the four that stopped being stricter, and the comment now says which
three remain and why.
A fourth, found while checking (a): the skip's own comment justified itself
with `img-src` being `'self' data:`, so a provider avatar would be "one
refused request per card". That is no longer true -- the avatar would load
now -- so the skip is a page capability gap rather than a policy consequence.
Recorded as such at the guard. Whether to lift the guard is a ruling-26
question and not this PR's.
Comments only. The parsed policy and document headers are unchanged, checked
through the harness readers the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): probe why Chrome never asks for the artifact image
CI's read was decisive: on Chrome 152 only the CSS background was requested,
while the `<img>` reported complete with a zero naturalWidth and a resolved
currentSrc. A request that went out and failed cannot produce both readings,
so the next probe asks the frame rather than the network.
On abort it now reads, inside the artifact frame: readyState, the init
script's own moment, document.images.length, every
`performance.getEntriesByType('resource')` name, the navigation entry types,
and for #remote its src, isConnected, complete, naturalWidth, currentSrc and
the outcome of decode(). A resource entry for a URL the rig never saw would
mean the request left the frame and died before reaching it.
Then it issues a `new Image()` at a URL that has never existed and reports two
seconds later whether the rig saw it. That splits the two live explanations: if
the fresh request is seen and the artifact's was not, the frame can fetch and
the parser-inserted element is the cause; if neither is seen, requests from
this frame are not reaching the rig at all. Subframe document commits are
counted from mount, because a second parse is a new window and leaves nothing
behind to count, and a second parse could be meeting a failure the first
cached.
`cdp sent` was empty on CI even for a request Playwright did record, so the
page's own session is blind to the frame. Chromium isolates sandboxed iframes
into their own process, srcdoc included, so flattened Target.setAutoAttach now
puts each child target on the same connection with Network.enable on the
child, and the attached list reports whether the frame is a separate target
at all.
The navigation arm gets the same reading, since CI showed it fails on its own
rather than behind the aborted image arms.
Verified by forcing the abort rather than assumed. Locally the reading prints
one subframe parse, decode resolved, every resource the document fetched, and
`fresh ... issued true seen true`, with the attached list empty, which is
consistent with this Chrome not isolating the frame and its page session
seeing the requests.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): time the artifact image against the frame's attachment
CI's second read showed the frame did issue the request -- it has a
resource-timing entry and decode rejected with EncodingError -- while the rig
saw only the CSS background, and a fresh image created later from the same
frame was both issued and seen. The remaining question is whether the entry
starts before anything was listening to that frame.
So the entry is now reported in full for the element under test:
responseStatus, transferSize, encodedBodySize, nextHopProtocol, startTime and
duration. A zero status with a zero transferSize is a fetch that reached the
network stack and came back with nothing, which is what an unintercepted
request looks like once `.invalid` fails to resolve.
Both sides of the comparison get a wall clock: `Target.attachedToTarget` and
Playwright's own `frameattached` now carry the moment they fired, and every
recorded request carries the moment it was seen. An entry that starts before
the attachment is the race stated rather than inferred.
Abort path only; the passing run is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): serve the artifact's https assets from a real TLS listener
Interception could not measure what the directive admits. Chrome 152 isolates
the sandboxed srcdoc frame into its own target and the parser-inserted `<img>`
is the document's first fetch, issued before interception attaches there: the
request escaped to the real network, `artifact-images.invalid` did not
resolve, and the rig recorded nothing while the frame's own resource timing
showed the fetch and a later fresh image was both issued and seen.
So the assets come from a listener that is already accepting before the page
exists. It cannot be raced: the request arrives or it does not, and either
answer is the measurement. Hits and referrers are recorded server-side, the
way this rig's cleartext origin already does it, and read per arm by nonce.
`img-src 'self' data: https:` matches on scheme, so `https://127.0.0.1:<port>`
exercises the same directive as any other https host.
Lifecycle: started in beforeAll before any browser, closed in afterAll beside
the other servers. Its certificate is generated per run by openssl into the
suite's own scratch directory under `mobile/.tmp`, which the root gitignore
already covers and into which the server writes a second `.gitignore` as well;
the key never leaves that directory and nothing trusts it, since the context
is created with `ignoreHTTPSErrors`. No arm shares state: one hit list keyed
by each arm's nonce, and the permissive-Referrer-Policy control stays what it
was, a second bundle server serving the page, because the control is the
document's header and not the image host's.
The navigation record moves off interception too. It is now `page.on('request')`,
one subscription over every frame, armed after the rig's own `goto` exactly
where the route used to be registered; the route stays only for what only a
route can do, refuse the navigation. That answers the top-nav arm's `recorded
[]`: its record depended on the same per-target interception.
And the arms stop swallowing their clicks. `click(...).catch(() => {})` made a
tap that never landed and a tap that produced no navigation the same empty
counter; `open()` now records the error and the two top-nav arms assert it is
null before reading any count.
One correction to the reading added in the previous commit. The resource-timing
fields came back zero for a request that had plainly succeeded: they are opaque
cross-origin. The listener now sends `Timing-Allow-Origin`, after which
transferSize, encodedBodySize and nextHopProtocol carry real values.
`responseStatus` still reads zero on a successful request, so the comment names
the three that discriminate rather than the four that are printed.
24/24 on both local engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare the artifact fetch and the attachment on one clock
The early-or-late comparison spanned two clocks and could not answer the
question it was written for. Every `at` in the request log is Node's
`performance.now()`, counting from process start; the resource entry's
`startTime` is the frame's own, counting from that document's navigation. A
frame entry reads as earlier than a Node attachment by roughly the process
uptime, so the comparison would have reported the race as confirmed on every
run, including runs where there was no race. A green CI would not have caught
it.
So the comparison is stated where both numbers actually live: `asked` against
`attached` in the request log, on the Node clock alone. `startTime` and
`duration` stay, labelled as the frame's own account and explicitly not
comparable to an attachment time. The module docstring says the same, so the
next reading added here starts from the rule rather than rediscovering it.
The commit message of b5e82065f3 carries the same overstatement and is left
as it stands; this is the correction.
Also the stale route-handler references, now that the asset listener records
the secure origin and the navigation record is a page subscription. Three were
in the review; two more were not, and both were stale for the same reason:
`waitForRecordedNavigation`'s docstring still credited the route with
recording a main-frame navigation, which stopped being true when the record
moved off interception, and the request log described a refusal as one the
request never reached a route handler with. The route now only refuses; it
counts nothing. The one remaining mention is the deliberate contrast in the
rig that says the record is the page's event and not the route's.
Comments only. 24/24 on both local engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The owner-loss group case spawned its fixture with a 100ms kill timeout and
then read the child.pid handshake the fixture writes, so under full-suite load
the launcher was SIGKILLed before it booted and the read failed with ENOENT.
Start the launcher detached, wait for the handshake, and only then send the
trial timeout's own SIGKILL, which leaves the same orphaned group the cleanup
path validates.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read only the files that name the params contract, not all 2,235 (OTA phase C)
`keeps every mobile import of the params contract type-only` timed out at 5,000 ms in Mobile Checks
on #21964 and once in the lead gate on #21969, while passing alone in about a second. Measured here
rather than guessed, on this tree:
| step | cost |
| ------------------------------------- | ------ |
| walk `app` and `src` (shared walker) | 4 ms |
| read 2,235 files, 11.3 MB | 42 ms |
| parse every one of them | 392 ms |
| visit every resulting AST | ~650 ms |
Exactly one of the 2,235 reaches the contract. The case was parsing and walking the tree of the
other 2,234 to find that out, and under full-suite load it had 4,216 ms of its 5,000 ms budget gone —
784 ms of margin, which is the intermittency.
The walk was never the problem and is unchanged: `censusSourceFiles` already excludes the generated
files that took this case from 1.5 s to over its budget in the first place. What is new is a first
stage between the read and the analyser. `ts.preProcessFile` is the scanner behind `tsc`'s own
dependency discovery: it reports every module reference — the same four shapes the analyser inspects
— without building a tree, and only the files it names as reaching the contract are parsed.
A substring search over the text would have been faster still and wrong: a specifier spelled
`rpc-contract` resolves into the contract and does not contain its name. The scanner decodes it,
and that shape is now one of the cases, asserting its own premise rather than claiming it in a
comment — its text does not contain `rpc-contract`, and the filter admits it anyway.
Two rules keep the new stage honest. Every shape the analyser flags, plus the three type-only
spellings and the escaped one, must survive the filter — a narrowing to `includes('rpc-contract')`
reds that with five shapes lost. And the set the filter hands on must be non-empty, because `[]`
offenders is also what a census reaching nothing at all prints: pointing `contractRoot` at a renamed
directory now reds all three cases instead of passing.
Verified still a fence: a planted value import of `repo-params` in `src/session` is reported by name.
4,216 ms to 319 ms under the full suite, 1,103 ms to 189 ms alone. No timeout was raised.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the boundary census's import spellings
The anti-slop gate reads `shapes` as structure rather than ownership, and
it is right: the array holds the spellings of one contract import, which
is what the case is about. `contractImportSpellings` says that, and the
`it` title and the comment below it follow.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): admit namespace re-exports the pre-filter cannot see
`ts.preProcessFile` does not report `export * as ns from '...'` or its
`export type * as ns` form. It reports `export *`, `export { X } from`,
`import ns = require()` and the rest, which is what made the filter look
sound. So a mobile file re-exporting the params contract under a name was
dropped before the analyser saw it, and the census passed empty on
exactly the shape its soundness case exists to catch.
Red first: both spellings added to the admitted list failed the case,
naming themselves.
The filter keeps the cheap path and adds the one shape the scanner omits,
matched on its own spelling rather than on the contract path. A text
match for the path is the wrong widening and this file already holds the
counterexample: the escaped-specifier case is a real import whose text
never contains the directory name, so a substring fence would miss it
while looking thorough.
The cost is bounded because the shape is rare, counted rather than
assumed: across the 2,236 files this census reads, the only match is this
file, through the fixture strings themselves. No product file uses it, so
the widening parses nothing that was not already parsed.
The timeout this PR exists to fix is unaffected. Under full-suite load
the slow case runs in 1179 ms against its 5 s budget.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): find namespace re-exports by token, not by text
`export * /* note */ as ns from '...'` is one namespace re-export and the
regex did not match it, so the pre-filter still dropped a file the
analyser needed to see. The same sequence split by a line comment is the
same hole. A text rule reads the characters between `*` and `as`; the
language does not.
Red first: both spellings added to the admitted list failed the case,
naming themselves.
The rule is TypeScript's own scanner now, walking for `export`, an
optional `type`, `*`, `as`, restarting from any `export` that breaks the
sequence. Comments are trivia and the scanner skips them, which is the
whole reason for the change.
Counted rather than asserted, over the 2,236 files this census reads:
1,005 carry an asterisk and reach the scan, which costs 118 ms for all of
them together; exactly one matches, this file, through its own fixtures.
The docstring says the `*` pre-check is not a rarity filter — a block
comment carries an asterisk, so most files with any comment reach the
scan — rather than implying the shape is rare enough not to matter.
The timeout this PR exists to fix is unaffected: under full-suite load
the slow case runs in 594 ms against its 5 s budget.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep the namespace re-export scan in sync across template substitutions
A bare scanner loop loses its place after `${...}`. The closing brace has
to be re-scanned as a template token, or everything after it is tokenised
as ordinary source and the rest of the file is read wrongly — so a file
with an interpolated template anywhere before a namespace re-export was
dropped again, which is the hole this filter exists to close.
The loop keeps `preProcessFile`'s own stack: push on `TemplateHead`, push
and pop plain braces inside a substitution, and on the brace that closes
one re-scan, popping only for a tail. Tokens inside a substitution cannot
be this declaration, so the matcher rests there and the work is staying
in step.
Red first: the interpolated-template spelling failed, naming itself. Two
more fixtures pin the stack rather than a flag — a brace inside the
substitution, and a template inside it. The braced one was red too; the
nested one passed before and is here because otherwise nothing exercises
that branch. Twelve shapes were probed against the new loop, including an
unterminated template and an `export * as` written inside substitution
text, which must not match.
Counts move, and the new number is the right one: 1,005 of 2,236 files
reach the scan, costing 173 ms together, and none matches — including
this file, whose spellings sit inside template literals and are template
text rather than tokens. The previous count of one was this file matching
through its own mis-tokenised fixtures.
Under full-suite load the slow case runs in 479 ms against its 5 s budget.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): add the page-route-unavailable refusal screen
The catch-all route landing next has no native screen behind it, so its
fallback cannot be a panel. Nothing imports this yet.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): move firstParam out of the source-control tree
Every caller is a route file under app/h/, and the import dragged
mobile-git-status.ts and the screen-state module into the closure of any
route that reads a param: 1989 modules (3 local) for a one-line helper,
against 1 from src/navigation/route-param-reader.ts. Pure move.
The three shell route suites drop their lucide-react-native mocks with
it; that barrel was only ever reached through the old home.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): serve any manifest route through a [...page] catch-all
Every page screen needs a route file under app/h/[hostId]/ today, so a
screen the desktop registers after a store build has nowhere to mount and
expo-router paints Unmatched. This adds one catch-all that hands any
host-scoped pathname to the shell; the manifest still decides, through
the same routeViewOf the other switches reach.
Measured with expo-router's own matcher on both platforms: every route
that has a file keeps it, index and the four .web.tsx siblings included;
only pathnames that reached Unmatched move.
The body lives under src/ because expo-router 55 reads a file's platform
from the first dot of its stripped name: [...page].web.tsx under app/
parses as platform '' and registers a second route rather than overriding
the first. Under src/ the stem is plain and Metro and the page builder
both resolve the sibling. getRoutes shows exactly one [...page] key on
each platform.
Registers no manifest entry, grant, hop row or PAGE_SERVED_SCREENS row.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): the page refuses an unrouted host path instead of Unmatched
The catch-all owns every /h/<id>/... pathname with no module, on the page
as well as in the app, so the render check's unmatched case can no longer
pass: measured in headless Chromium, /h/<id>/not-a-route paints the
refusal with no page or console error.
bridge-caps.ts records that C8 closes the C1.7 class for the host
subtree, and its dot-segment note is rewritten to the measured mechanism:
getStateFromPath normalizes the href through new URL(href, 'file:') in
getUrlWithReactNavigationConcessions before cleanPath sees it, so
/h/..?x lands on the app's root screen rather than on a host screen with
hostId '..'. Refusing it stays correct.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep the web-overrides allowlist in append order
The catch-all entry was added with a whole-file sort, which rewrote 116
lines for one addition and buried it. The test compares sorted sets, so
the order on disk is free; append order is what makes the diff readable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): leave the refusal on the encoded host route
The Back href was a raw template, so a host id carrying a slash built
/h/a/b — two segments, which the catch-all that rendered the refusal
matches with hostId now "a". The control looped back into the screen it
exists to leave. hostStackHostRoute already encodes it and is what the
notification path pushes through.
hostId is a string: firstParam returns one, so the undefined arm and its
?? '' were unreachable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the catch-all's fallback binding and its way out
catch-all-page-route.test.tsx mocks both the shell and the refusal, so
fallback={refusal} was unpinned: mutating it to null left that file at
10 passed. This drives the other half with the real shell screen and the
real refusal under it, stubbing only what the session reducer settled on.
Four cases, each measured against a mutant: fallback={null} reds three,
push instead of replace reds two, a raw /h/${hostId} template reds the
five-shape encoding case.
The checking case is the presence precondition the rest need: before the
flag read settles the switch returns the refusal on its own, with the
same text and the same control, so an assertion on the refusal alone
would pass against a screen no shell ever rendered.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the route-body follower resolve every shape or fail
The follower read only `export { default } from 'x'`. A route file doing
`import X from 'x'; export default X` and mounting the shell without
shellScreenRoute left the census at 5 passed: the body was never opened,
so the file read as "not a switch" — the one answer a census must never
give by default.
Both shapes are followed now, and an unresolvable one is named rather
than skipped. Measured against four mutants: a shellScreenRoute call
dropped from the re-exported body reds the rule; an import-then-export
route mounting the shell reds two cases naming the file; a default from
a package specifier, a file with no default, and a re-export with no
module each red the new resolution case with the reason.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): type the host-node lookup in the catch-all state test
The tests-typecheck ratchet reds on findAllByType with a host string:
react-native is mocked to strings here, which is not an ElementType. A
findAll predicate on node.type is the same lookup and checks.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say why the refusal does not absorb the other shell states
The refusal answers one question — this build cannot serve this route —
and offline, checking and the protocol wall answer different ones that
are each true for a screen only the page has. Absorbing them would tell
someone with no connection that the screen does not exist.
Written where fallback is bound, and driven: an offline session through
the catch-all paints the connect message. Mutating the shell to return
fallback for offline reds that case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the type the shell-view probe stands in for
The anti-slop gate refuses a broad `object` parameter, and it is right
here: the probe forwards every prop to its host node, so the type it
accepts is the view's own. Type-only import, so the module's
requireNativeViewManager call is still never evaluated.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): send the refusal to the app root when it has no host
firstParam answers an absent hostId as '', so Back called
hostStackHostRoute('') and landed on /h/ — which expo-router's own
matcher resolves to the h layout with no child, a press that paints
nothing and leaves the dead end in place. The app root lists hosts and is
where ProtocolBlockScreen sends the same gesture from the same position;
the label follows the target rather than outliving it.
Also through useRouteHandoff rather than useRouter, which is the same
defect on the other side: the page renders this screen through the
catch-all's .web.tsx sibling, and there a bare replace navigates inside
the WebView to a route the page does not carry instead of leaving it.
ProtocolBlockScreen already uses the seam; the router-seam censuses cover
src/session, src/files and src/source-control, not src/mobile-web-shell,
so nothing caught it.
Costs one module in the page closure (host-stack-navigation.ts): every
module the seam reaches is already in the layout's closure.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): repoint C4.4's source-control web sibling at the moved reader
The merge brought in a route file that imports firstParam from the
source-control screen state, which this branch emptied. Git merged both
sides cleanly because neither touched the other's lines; tsc is what
catches it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): mock dictation's device half in the catch-all state test
The merge put the audio verbs in the shell screen's closure, so this file
reaches @orca/expo-two-way-audio, whose module touches the Expo global at
import. Same two mocks MobileWebShellScreen.test.tsx carries for the same
reason; what each verb does is bridge-audio-verbs.test.ts.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): census the router seam under mobile-web-shell
The three landed censuses walk src/session, src/files and
src/source-control, which is how PageRouteUnavailableScreen shipped with
a bare useRouter and nothing caught it until CodeRabbit.
This tree needs a shape of its own because it holds both halves: the rule
cannot be "no router" when MobileWebShellScreen and useShellStackPop are
the app end the page's navigate and navigate-back notifies arrive at.
Both are named with the reason, and neither has a .web.* sibling, so
neither runs inside the page.
Red first: with the bare useRouter put back, two of the four rules fail
naming the file —
+ "PageRouteUnavailableScreen.tsx (useRouter)"
- "PageRouteUnavailableScreen.tsx"
The walk covers 72 product modules, asserted above 60, so the empty
finding list is over a non-empty walk.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): split the rich editor document's stylesheet and markup apart
The body constant carried the tail of a `:root` block, every CSS rule and the
editable surface's markup in one string, which only the HTML builder could
splice. A page mounting the document needs the stylesheet and the markup
separately, so they become a function over the theme and a constant.
Byte-for-byte inert: `mobile-rich-markdown-editor-document.test.ts`'s digest of
the shipped document is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give the keyboard-inset normaliser its own module
It is the host's half of the inset, read by the controller, and it sat in the
module holding the document's in-page script. The script is about to become
ordinary TypeScript under `rich-markdown/`, where a native-side normaliser does
not belong.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): the rich editor's document becomes scope-threaded modules and a factory
The editor's ~600-line program lived in seven string constants a concatenator
glued into one `<script>`: unreadable, untypeable, and unreachable from a page,
which is where the OTA shell has to run it (ruling 26).
It is now ordinary TypeScript under `src/components/rich-markdown/`. Every
function that touches editor state takes `scope: RichMarkdownEditorScope` first,
`createRichMarkdownEditorDocument(host)` builds the scope, runs the start
sequence and returns `{ send, stop }`, and the six window reads the script did
are host seams with those reads as their defaults: `postToHost`, `promptForUrl`,
`keyboardInsetSource`, `clearTimer`, `getSelection`, `getDocument`.
`runCommand` is async because a host that answers the URL prompt with a modal
cannot answer synchronously; the thirteen commands that never wait stay one
synchronous act.
No module holds a `let` and none does work at parse time (rulings 20, 21), so a
second mount starts from its own state and `stop` takes back both the surface's
four listeners and the viewport's two.
The native document is an esbuild IIFE bundle of `native-document-entry.ts`,
written beside the terminal document's artifact by a fifth postinstall
generator. Nothing ships it yet: the HTML builder still splices the old strings,
which the next commit changes.
Red-first: `rich-markdown-document-parse-time.test.ts` and
`rich-markdown-host-seams.test.ts`. Their readers are the terminal census's,
extracted to `src/test-support/webview-document-census.ts` and pointed at both
documents rather than copied.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): ship the bundled document and retire the editor's script strings
`buildMobileRichMarkdownEditorHtml` splices the esbuild bundle of
`src/components/rich-markdown/`, and the seven string constants and their
concatenator go. `escapeInjectedJavaScriptString` stays: it is the escape for
`injectJavaScript`, which is still how the native host reaches the document.
Equivalence, since a byte golden over the script cannot survive a bundler:
- `rich-markdown/native-document-bundle.test.ts` evaluates the shipped artifact
exactly as the WebView does — its markup, its bridge, its `execCommand`, its
`prompt`, its `visualViewport` — and drives it through the injected handle:
`keyboardInset` then `ready`, all five members, a markdown round trip through
the real escape, an edit under the host's generation, every toolbar command's
engine verb, the `javascript:` refusal, a tapped link, and the module list.
- `mobile-rich-markdown-editor-document.test.ts` keeps a byte pin, now over the
page around the document. Measured on main's own document with its script
region removed and on this one: 5,621 bytes, both
`5054e1d5c87e4ce1805d4856ddc8bf36804e697675e6013d84da453d3e81af25`. The
whole-document digest it replaces was `1ef29c88…`, 29,852 bytes.
Every assertion `mobile-rich-markdown-editor-html.test.ts` made by extracting
functions out of the emitted text is kept, aimed at the modules:
- nested/ordered/task list rendering and serialization, entities, explicit
numbering, the parent-start fallback, read-only checkboxes →
`markdown-round-trip.test.ts`, over real elements rather than shaped objects.
- the emitChange/setEditable guards and the generation carried through a
replacement → `editor-content.test.ts`, behaviourally.
- dismissKeyboard, the tapped caret, the label tap, the restored caret, the
end-of-document fallback, the detached caret → `editor-selection.test.ts`,
with a blur that drops the ranges the way WebKit does.
- parseable script and the injection escape stay in the HTML test.
New with the factory: `document-lifecycle.test.ts` — stop takes the four surface
listeners and the viewport observer off, a second mount is its own document, two
documents do not share `editable`, and a start that throws unwinds.
`use-mobile-rich-markdown-editor-controller`, `MobileRichMarkdownEditor` and the
web fallback tests are untouched and green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the document's mutable bindings from the tree, not the line start
The census matched `/^(let|var) /gm`, so `export let`, a declaration indented
inside a top-level block and a `for (let …)` head were all invisible — three
shapes of the one binding two documents would share — and its single
precondition proved only the shape it could already see.
`moduleLevelMutableBindings` walks the program instead and stops at every
function body, because a binding one call owns is not module state. Its
preconditions are one per shape, with the kind each reports, and a negative case
over a `const` and a function-local `let`/`var` so the empty list is a
measurement rather than a reader that refuses everything.
Red-first: `export let pendingReport = 0` planted in `keyboard-inset.ts` reds it
with `keyboard-inset: let pendingReport`, which the old matcher passed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): pin both WebView document bundles to the mobile root
esbuild writes each module's path into a bundle as a comment relative to the
working directory, and neither generator set `absWorkingDir`. So the artifact's
bytes followed the cwd of whatever postinstall run wrote it: measured from the
repo root, `mobile/`, and `mobile/src`, three digests — and from outside the
repo the comments carried `/Users/<name>/…`, a machine path in the one file
every bundle test compares against a build it makes itself.
Both generators now pin the mobile root, so the four cwds measured agree, and
both bundle tests carry the pin: a digest built in a child process from the OS
temp directory equals the committed artifact's, and no comment in either
artifact is an absolute path or climbs out with `../`.
`build-terminal-document-script.mjs` had the defect verbatim on main; C1 copied
its shape, so both are fixed here rather than leaving the original to be found
again. Neither artifact's bytes move: both were generated from `mobile/`, which
is what `absWorkingDir` now names.
Red-first: deleting the `absWorkingDir` line from either generator reds that
generator's case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): cover the getSelection seam's override, not just its default
Five of the six seams had both halves and this one had only its window default,
which is the half that cannot fail on the page: there the caret has to come from
the object the host hands over, because a document mounted inside a screen
shares `window` with every other field on it.
The case gives the document a selection of its own, blurs the surface the way
WebKit does — dropping the ranges, which is the whole reason a caret is saved —
and reads the restored caret back out of the host's object. The window's own
selection stays empty throughout, which is what says the default was never
consulted.
Red-first: `rememberSelection` reading `window.getSelection()` instead of the
field reds it; every other case in the file stays green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the editor document's stop cancel its pending timer
`stop` took the surface's four listeners and the viewport's observer off and
left the input timer, while the scope kept the handle and the `clearTimer` seam
kept the means to cancel it. A listener comes off with the element it was on; a
scheduled callback holds the scope and fires into a document the host has
already unmounted, posting a change under the generation of content it has
replaced.
`stopEditorContent` cancels it through the seam and clears the field, and the
sequence runs it last — after the listeners that could have scheduled another
one are gone.
Nothing schedules the handle today. The cancel is here because the seam and the
field exist for the day something does, and that is not the moment to discover
`stop` never reached it. The case plants the pending change rather than waiting
for a debounce, and carries its own control: the same timer posts while the
document is running, and posts nothing once it is stopped.
Red-first: dropping `stopEditorContent` from the sequence reds both that case
and the parse-time census's start/stop set comparison.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct the postinstall generator count in both censuses
Two comments said four generators and six generated files. There are five
generators writing six files, and the six are not the six either comment
described: `census-source-files.ts` still named the page's copy of the terminal
document, which ruling 25 retired and #21962 stopped ignoring, while C7.10 C1
added the rich Markdown editor's.
Both now name the lists of record — `mobile/package.json`'s postinstall for the
generators, `mobile/.gitignore` for the files — and say the count is a reading
that grows rather than a fence, which is what made the old numbers wrong twice
over.
Verified against both lists: 5 and 6.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say which digest is the document and which is the page around it
The docstring put main's whole-document digest and byte count in the sentence
introducing the shell pin, so it read as if `1ef29c88…` and 29,852 bytes were
what the constant below asserts. They are not: that digest is of main's whole
document, script included, and nothing in the file reproduces it. The constant
is of the document with its `<script>` region emptied, taken on main's document
and on this one.
Both are now named and separated, with what each covers and why the shell one
was read twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the parse-time fixture by its role
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop an editor command whose dialog answered after the host moved on
C7.10 C1 made `runCommand` async so a host can answer the URL prompt with a
modal. Inside the WebView that changes nothing — `window.prompt` resolves within
a microtask, and the host reaches the document through `injectJavaScript`, which
is a later task — but on the page the modal is a real task boundary, and while
it is open the host can replace the content, make the editor read-only or
unmount it entirely. The continuation ran anyway: `createLink` against markdown
nobody chose, and a change posted under the new generation carrying an edit made
against the old one.
`acceptsCommands` is the question both halves ask: not stopped, still editable,
still the same generation, still contenteditable. `insertUrl` asks it before
`execCommand` and `runCommand` asks it again before emitting, each against the
generation read before its own wait.
The scope gains `stopped`, which `stopRichMarkdownEditorDocument` sets.
Inert on native, where no state can change across a microtask, so the answer to
both questions is the one the old code assumed.
Red-first: with either check removed, the new case reports
`[ 'createLink', 'createLink' ]` against `[ 'createLink' ]`. The case carries its
own control — an answer that arrives while nothing has moved is still applied
and still reported.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the editor's block reader always consume a line
`markdownToHtml` looped forever on `# `, `- ` and `1. `. `isBlockStart` admits a
marker followed by a space, and the list test admits the same, but the heading
reader requires text after the hashes and `parseListLine` requires text after
the marker — so on those lines the list branch consumed nothing and returned the
index it was given, and the paragraph loop gathered nothing and pushed an empty
paragraph without advancing. A one-line file the host handed to `setMarkdown`
froze the WebView.
Two guards, both by the same rule: a branch may only commit if it moved the
index. The list branch falls through when its run is empty, and the paragraph
falls back to the line itself when it gathered none.
Present on main verbatim, so this is inherited rather than introduced — but the
fix is observationally inert, because the only inputs it changes are the ones
that previously never returned. Every input that produced output produces the
same output.
Evidence, from a probe that bounds the loop from the inside rather than waiting
on it: before, `# ` and `- ` both UNBOUNDED; after, twenty marker and fence
shapes all return. The pinned cases carry their own control, `# ok` and `- ok`,
so the fallback is not swallowing the readers it falls back from.
A red-first case is not possible here: without the fix the case does not fail,
it hangs the worker. The probe above is the measurement.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): see every declaration that runs as a document module is evaluated
The parse-time reader inspected only variable declarations, while
`DECLARATION_KINDS` admits classes and default exports. So
`class A { static value = install() }`, a static block, and
`export default install()` all passed a census whose whole job is to refuse
exactly that — and a static field reading `document` passed too, which is the
remount defect the rule exists for, wearing a different shape.
Three shapes now, each reported by what it does rather than what it looks like:
a variable initialiser, a class's static members, and a default export that is
an expression. `DECLARES_WITHOUT_RUNNING` keeps the last one from walking into
the body of `export default function () {}`, whose calls run when something
calls it.
The preconditions are one per shape, with a negative case beside them: an
instance field runs per `new` and nothing in a document is ever constructed, and
a default-exported function declares a body rather than running one.
Inherited from the terminal's census, which had the same reader; both use this
one, and both are green.
Red-first: removing the class branch reds the new precondition case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read lifecycle exports from the tree, not from one exact spelling
The reader was a regular expression needing `export function`, one line, the
scope parameter and no return type. `export async function startX(`, a return
type, or a parameter list the formatter wrapped made a real lifecycle export
vanish — and the comparison it feeds is a set against the names the sequence
calls, so a function missing from *both* lists makes them agree. A start nobody
runs would have read as a start nobody needs.
It now qualifies a function by what it is: exported, named for its lifecycle,
and taking the document's scope as its only parameter. That last clause is
ruling 20's own wording — a start takes nothing the scope does not already carry
— and the regex was enforcing it by accident, through the single parameter its
pattern happened to allow.
Surfaced by the change: the terminal's `startEdgeScroll(scope, dir)`, which the
regex never matched and the sequence never calls. It takes a direction, so it is
the overlay's act for a drag rather than a module's lifecycle, and the one-
parameter rule refuses it for the stated reason instead of by accident. Both
censuses are green.
Red-first: restoring the regex reds the new precondition case, which covers
`async`, a return type and wrapped parameters, with refusals beside them for a
two-parameter start, another document's scope type, and an unexported function.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): serve dictation capture over four native audio verbs
The page owns dictation's state machine and speaks `speech.dictation.*` to
the desktop, where transcription runs; the microphone is the shell's. So the
shell gains `native.audio.start|read|stop` and `native.wakelock.set` — four
rows, four grants — and rings what the microphone produces at the page's own
pending-audio budget rather than pushing bytes the page would hand straight
back.
`native_audio_not_capturing` joins the refusal vocabulary: a read for a
capture this session does not have is the one refusal the page must tell from
a device that failed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): take dictation capture through one seam on both hosts
`use-mobile-dictation.ts` held the microphone and the wake tag directly, so
the page had a hook whose every device call was a stub answering denied. The
five calls move behind `src/platform/dictation-capture.ts`: natively the same
calls in the same order, on the page the shell's four verbs, with the drain
raising the events the engine emits.
The tag bookkeeping stays where it was and stops importing `expo-keep-awake`:
two calls come in through the seam and the pools, the queue, the timeouts and
the retries are the same on either host.
The chunk sender is untouched. A chunk carries raw PCM because that is what
the budget counts and what `speech.dictation.chunk` is built from, so the page
pays one decode of 32 KB a second rather than the flow carrying two shapes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* perf(mobile): drain the shell's audio ring on a 500 ms batch
One `speech.dictation.chunk` per native microphone event is 31.25 forwarded
requests a second, and each holds one of the bridge's 64 in-flight slots for a
whole desktop round trip. Measured over ten seconds against a two-second link:
63 in flight at the peak and one slot left for the rest of the page.
Drained every 500 ms instead: 5 in flight, 60 slots free, the same 42 KiB/s,
and 38 frames out and 34 back for the whole session. The frame cap was never
the bound — half a second of PCM is 3.3% of one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): derive dictation's grant rows from the route closure
Which routes need the four audio verbs is a census, not a hand list: the rule
reads each registered page route's own closure and holds its `grants` to what
that closure reaches. Vacuous on today's route list — the session route is the
only closure carrying the seam and is not registered yet — so a control runs
the same rule against the session module and names all four as missing.
The closure also records what the seam took off the page: `@orca/expo-two-way-
audio` and `expo-keep-awake` are gone from it entirely, and removing the web
file puts four of the vendored stub's modules back.
The mic control's render case found a real one. A start the shell refused
outright left the button on "Starting voice dictation" with no way back, which
is every tap on a route without the grants. It reports the refusal and returns
to idle.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep the mic control's render case inside the tests typecheck
A `let` the renderer assigns inside a callback narrows to `never` afterwards,
and the mocked `Pressable` took `children` as `unknown`. Both are type-level
only, and the ratchet is the gate that notices: a test outside `tsc` can pin a
shape that stopped existing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hand over the audio still in the ring before stopping the shell
`end` cancelled the drain and stopped the capture without a last read, so up to
one drain interval of the utterance's tail — 16,000 bytes, the 400 ms a user is
still speaking as they lift the button — was discarded on every stop. The
reviewer's probe spoke 12,288 bytes in the last 400 ms and the page delivered
none of them. Natively that audio is already in the hook's hands, so this was a
page-only loss of the end of every sentence.
`end` is now asynchronous: it cancels the timer, waits for any read in flight,
reads once more, and only then stops the shell — stopping first takes the
capture away and the read after it is refused. `stop()` awaits it before it
stops accepting chunks and before it takes the pending set, or the tail would be
dropped one line later and `finish` could overtake the last send.
A release still skips the last read: the screen is going away and there is
nobody left to hand the tail to.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): release the page session's wake tag when the session ends
The wake-lock server held its tags per instance and a new one was built per page
session with nothing ever disposing it, so a tag a session took was never given
back and the screen stayed awake for the app's lifetime. The page is a document
that can navigate, fault or be swiped away mid-dictation, so nothing else was
ever going to call deactivate. Its own docstring claimed the opposite.
It now answers `{ serve, dispose }` and is disposed with the session exactly as
the microphone and the staged media handles are. `dispose` drops only what is
still held, so a tag the page already gave back is not deactivated twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the capability flag no screen reads
`canCaptureAudio` answered four grants to nobody and had no test. The fence that
actually holds is the per-verb `ungranted` check every member already makes
before a frame is sent, and the mic control's render case pins what a screen
does with it.
The surface's member list is pinned instead, so the next flag with nothing
behind it has to be added there on purpose.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-measure the closure the dictation census records, and make its controls real
Three corrections to the census, all of them about the census lying rather than
the product being wrong.
The recorded number was four modules; measured on this head it is eight — five
from `@orca/expo-two-way-audio` and three from `expo-keep-awake` — for a net +7
once the local file that left is counted. The absolute closure counts are
provenance in the docstring and are not asserted, because every merge of main
moves them. The absence now has a precondition: both package names are resolved
from the install, so a substring matching nothing fails as a typo.
The case named "reads the census file" read no file. It reads the shell's own
verb table through `import()`, behind the closure guard, so a grant the shell has
no row for reds instead of agreeing with itself.
And the grant control re-implemented the rule's filter inline. Both the rule and
the control drive one function now, over the entry C7.7 would write if it copied
its neighbours' grants.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): serialise the shell's audio starts and stops
Two starts racing the OS permission prompt — a page reloaded while it is up,
which is the case the replacement rule exists for — both reached `listen()`, and
the second overwrote the first's handlers without removing them. The engine went
on calling into a capture nobody could read, and `dispose` freed one of the two.
A stop that overlapped a start found nothing to end and the start opened a
microphone behind it.
Starts and stops now run one at a time in the order the page asked for them, and
a start that comes back after the session ended opens nothing. Reads stay off the
queue: they must not wait behind an opening capture, and a read with no capture
is already a refusal.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): end a capture on the same two interruptions on both hosts
The page treated any interruption as the capture being taken away, while the
native seam has always gated on `began` and `blocked`. So an `ended` on its own —
the OS handing the session back after a notification chime — cancelled a live
dictation on the page and did nothing natively.
The rule is now one predicate beside the vocabulary it belongs to, read by all
three places that decide it: the shell, which stops filling its ring; the native
seam, which raises it off `onAudioInterruption`; and the page, which raises it off
a read reply. `recording` still ends the page's capture whatever the kind, because
a capture the shell no longer has is gone however it went.
The native half had no test of its own, which is why the drift was invisible. It
has one now: the five calls it makes, the chunk it hands over, the wake tag, and
which interruptions end it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): type the chunk sender as what its callers pass
The sender's parameter was the native `MicrophoneDataEvent` though both callers
hand it a `DictationCaptureChunk`. Structurally the event is the wider type, so
it accepted either and read `droppedBytes` off neither — a page whose audio the
shell's ring had dropped would have sent it as though nothing were missing, and
nothing would have failed to compile.
Typed as the chunk, with a compile fence beside the seam holding both directions:
a chunk is accepted, an event is refused, and a raw buffer is not a chunk. The
seam normalises the bytes, so the widening the sender did has no caller left.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the session closure with dictation's page modules on it
The capture seam moves this count down rather than up. Measured at 2d697a4012
and at this head: modules 4,324 to 4,319 and local modules 974 to 977. Three
local modules join — the page's capture seam, its contract and the audio verb
shapes — and eight vendored ones leave, because the seam is what stops the page
importing a microphone it does not have: five of `@orca/expo-two-way-audio` and
three of `expo-keep-awake`, replaced by four verbs the shell answers.
Named beside the sentences already there, with the counterfactual that puts the
eight back pointing at the census that runs it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the session closure over C2.9's two bridge modules
C2.9 moved this pin to 4,326 by putting the page-route grants and the manifest
grammar behind `bridge-envelope.ts`, which every page closure reads. Dictation's
capture still moves it down from wherever it lands: measured at 5d13a70ea3 and
at this head, modules 4,326 to 4,321 and local modules 976 to 979.
Three local modules join — the page's capture seam, its contract and the audio
verb shapes — and eight vendored ones leave, five of `@orca/expo-two-way-audio`
and three of `expo-keep-awake`, replaced by four verbs the shell answers.
Recorded beside C2.9's sentences rather than in place of them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* perf(config): build each route closure once in the dictation census
Every `mobileWebAppRouteClosure` call is a full esbuild metafile build, and the
cases here ask about six route modules across nine of them — fifteen builds. The
CPU that cost tipped two timing-sensitive neighbours in this shard over: a
benchmark whose child has 100 ms to write a pid file, and a census globbing a
scratch tree another test was removing. Neither is reached by this file and both
are fragile without it; the added load was the difference. With the census
excluded the shard was green, with it three runs of three were red.
Memoised per route module, so six builds. The shard still fails intermittently
on this machine for its own reasons, but not because of this file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): gate the dictation census's verb-table case on the mobile install
`import()` defers when a module loads, not what loading costs. Vite transforms
the file at that moment and resolves the nearest `tsconfig.json` for it, which is
`mobile/tsconfig.json`, which extends `expo/tsconfig.base.json` — absent on the
root-only shard, so the transform threw `TSConfckParseError` and reddened
`test / tests node 24 1/8`. The comment claiming the dynamic import avoided that
was wrong.
Gated on `mobileWebAppDependenciesPresent()`, the same guard the closure cases
use, which is the only thing that keeps a mobile module off that shard.
Reproduced both ways in this tree by moving `mobile/node_modules` aside: before,
1 failed with that error; after, 3 passed and 6 skipped; with the install back,
9 passed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the page's capture end idempotent
The hook's interruption handler is `() => void cancel()`, and `cancel` reaches
`capture.end()` synchronously through `closeDictationAudio`. So the last read in
`end` could raise an interruption that called straight back into `end`, whose own
last read was refused for the same reason the first was — the shell has no
capture — and the recursion issued bridge reads until the page ran out of memory.
The pin crashed the test worker with `JavaScript heap out of memory` before the
fix.
A second `end` returns the first one's promise, assigned before anything can
await so a handler re-entering from inside the read finds it set. `begin` clears
it, because the seam is memoised per client and the next dictation on the same
screen has to be able to drain — pinned by a case that ends, starts again and
reads the new audio.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): give back a wake tag whose activation landed after dispose
`held.add(tag)` ran after the device call, so a `dispose()` falling between the
activate and its reply walked an empty set and the tag was recorded afterwards.
Nothing walks that set again, so the screen stayed awake for the app's lifetime —
and the page is a document that can be swiped away mid-dictation, which is
exactly when that window is open.
A tag that lands after the session ended is deactivated on the spot and reported
to the page as not held.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): swallow the native audio session's shutdown failures
The contract says `end` never rejects, and the native one did: it is `async`, so a
throwing JSI binding rejects rather than throws. Every caller reaches it as
`void capture.end()` inside a synchronous `try`, which cannot see a rejection — so
a device that would not stop recording left an unhandled rejection, and the
cleanup the `try` was written to protect was never what was at risk.
`release` was worse in kind: it runs bare in the unmount path, so a throwing
`tearDown` took the wake tag's release and the desktop's cancel with it.
Both log and continue. Pinned behaviourally against an engine that refuses to
stop and a tear-down that throws; the source case that claimed the hook's `try`
was the guard now says where the guard actually is.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the order stop() hands the tail over in
The tail fix depends on `await capture.end()` running before chunks stop being
accepted, and nothing held that: the source check only asserts `end()` precedes
`Promise.allSettled`, which both orders satisfy, so reversing the two lines left
the whole mobile suite green while the page silently dropped the end of every
sentence.
Driven against a capture whose `end()` delivers a chunk — what the page's seam
does and the device's never does, which is why only this case can tell the orders
apart. It asserts the tail reaches the desktop as `speech.dictation.chunk`, with
those bytes, before `finish`; reversed, no chunk is sent at all.
The mocked seam is one object for the module's life, because the hook keys its
teardown effect on the capture's identity: a seam returning a fresh object per
render cancels the dictation on every render. Both real seams are stable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep a wake tag recorded until the device really drops it
`held` was cleared whether or not `deactivate` succeeded, so a tag the device
refused was forgotten. The page's owner queues exactly that failure for a retry
(`pendingCleanupTags`), and the retry arrives here as another `active: false` —
which a shell that had already forgotten the tag answered without calling
anything, leaving the native tag on for the life of the app.
The boundary: the set means "the device still has this tag", not "the page asked
for it". That keeps the reason the set exists — never call `deactivateKeepAwake`
for a tag this shell never took, since its failure would read to the page as a
wake lock it could not drop — while letting every retry for a live tag through.
Always reaching the device regardless of the set would have traded the second
property for the first.
Pinned with a device that refuses once and then accepts, and with a dispose whose
deactivation is refused.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): tear the audio device down for a start that lost the race
A start that came back after `dispose()` returned without ending the engine, and
the local `end()` is a no-op with no capture, so nothing tore down the session
`initialize()` had just brought up. Nobody else would: the dispose had already
run and no capture was ever recorded. The device's audio session stayed up for
the life of the app.
It ends the engine on that path now. Pinned on both places the race can be lost —
inside the permission prompt and inside the open itself — each asserting exactly
one teardown and no live listeners.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drive the stop-tail pin through the typed fake client
The hand-rolled client fake needed three type assertions to stand in for an
`RpcClient`, which the changed-code quality gate refuses on new lines. It drives
`createFakeRpcClient` instead — a real one — answering each request as the hook
makes it, and reads the chunk's base64 by narrowing rather than asserting.
Re-confirmed the pin still reds on the reversed order after the rewrite.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): clear the page capture's end when it settles
A finished `end` stayed in `ending` until `begin` cleared it, and `begin` is not
guaranteed to run between two ends: `open` starts the shell recording, and a start
that goes stale after the hook sets `activeIdRef` cleans up through `capture.end()`
without ever committing. The second end answered from the first one's settled
promise and never issued `native.audio.stop`, leaving the shell holding a live
microphone.
Cleared on settle instead, and only for its own flight. The re-entrancy the latch
was really for happens while the promise is still pending, so guarding the flight
is enough. `release` keeps its own flag rather than a settled `ending`, which now
clears itself and would unlatch it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): serialise the shell's wake tags and record them before compensating
Two ways the device could end up holding a tag nothing would ever give back.
A release that arrived while its own activate was still in flight read `held`
before the activate had recorded anything, found nothing, deactivated nothing and
reported the tag off; then the activate landed and the device stayed on. `held` is
read and written across an await, so operations are now chained per tag. Per tag
rather than per server, so one hanging device call cannot hold up another
dictation.
And on the late-tag path, a compensating deactivate the device refused was
swallowed while `{ active: false }` was returned: the device still held the lock,
`held` lacked the tag, `dispose` had already walked the set, and the page believed
an activate had succeeded. The tag is now recorded as soon as `activate` resolves,
deleted only once the device has really dropped it, and a refusal rejects so the
caller's retry path runs.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let dispose re-read the tag set once its turn comes
Queueing dispose behind each tag's own operations introduced a call the module
says it does not make: a release already in flight can give the tag back before
dispose runs, and deactivating an unheld tag is a native call whose failure would
read to the page as a lock it could not drop.
Re-reads the set when the queued action runs rather than trusting what it held
when dispose was called.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the dictation census to the routes C4.4 registered
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The sweep asserted that its minimum noise cost stays above 0.5 bytes per
pixel, a floor chosen against the pinned Chromium's 0.543986. The runner's
Chrome 152 encodes the same seeded noise at 0.480898, so the floor sat inside
the spread between two encoders and failed a green run on a measurement that
was noise. The averaged-noise arm the floor exists to catch reads under 0.3
on both. The floor is now one named constant at 0.4, used by the sweep and by
the arm that proves the floor catches an averaged canvas, with both readings
recorded beside it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A remote pane asks the host two different questions about its terminal, and
they consult different amounts of state. `session.tabs.list` hydrates first —
`listMobileSessionTabs` runs the workspace-session hydrate with
`allowAttachedWindow: true`, then restores live paired-renderer terminals.
`session.tabs.activate` hydrates with no options, which is a no-op whenever an
authoritative window exists, so it answers `tab_not_found` from state the host
never filled in. The client treated that as removal evidence and surfaced
"Remote terminal was closed." over a terminal the same host was still
reporting connected and writable.
Activation's absence answer is now non-authoritative: the bounded inventory
poll below adjudicates. A surviving sibling leaf with this leaf gone still
returns removal evidence and still surfaces the toast; nothing conclusive
inside the window stays unknown liveness, which parks a retry instead of
asserting closure. `null` leaves both return unions.
Fixes#21852
* test(mobile): wait for the preview frame in its main world, and probe the world that hung
Three cases spent their whole 180s on CI's Chrome inside `waitForSelector('#marker')` while
the diagnosis reported, from the same frame, `readyState: complete` and `marker: true`.
Those two readings ask in different worlds. `frame.evaluate` needs only the frame's main
execution context; a selector wait needs Playwright's injected script in Chromium's utility
world, an isolated world created per document by a command whose failure the driver swallows
and whose creation event it drops for a frame the driver considers stale. With `timeout: 0`
a world that never arrives is a wait that never ends.
So readiness is main-world polling now: the frame is resolved again from `page.frames()` on
every attempt and the predicate runs through `frame.evaluate`, still bounded by the case's
own `ctx.signal` and still ending in the diagnosis. The evaluate is abandoned after a second
so a frame that never answers cannot outlive its own replacement.
The diagnosis gains the reading that would have settled this in one run: a bounded
`utilityWorld` probe per frame, printed beside the main-world reading, so the split is
measured rather than inferred again. The competing explanation is ruled out in code --
Playwright closes a detached frame's scope with an error that every wait races, so a stale
Frame rejects rather than hangs.
Not proven red-first. Chrome 152 is the only engine that has shown this and it is not
available here; chromium 147 and WebKit 26.4 both build the utility world and both report
`utilityWorld "resolved"` for the sealed `srcdoc` frame. What is proven locally: 18 of 18 on
both engines, and a deliberately marker-less artifact still ends in the diagnosis, with
exactly one line per case naming the wait that hung.
That last part needed a fix of its own: an abort listener left behind by a wait that had
already resolved printed its stale reading at a later wait's timeout, so every timeout spoke
with more voices than it had hung waits. The listener is dropped on the way out.
In-frame `frame.click` needs the utility world too and is left alone: a main-world click is
not a user gesture, and the gesture is what those cases assert on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): take the preview's refusal from the browser's report, not from a listener in the frame
The utility-world theory is refuted: CI's Chrome answered `utilityWorld "resolved"` on both frames
and the case failed anyway, with the widened frame reporting the artifact parsed, the CSS
background's `img-src` refusal recorded, no `script-src` refusal, and no script run. Two different
things produce exactly that reading. The policy refused the script and the frame's own listener was
not there to see it, or the sandbox refused it first, which raises no violation at all -- and a
listener inside the frame cannot tell them apart, because in the second case there is nothing for it
to hear.
So the evidence moves to where neither depends on timing: the sealed server now appends `report-uri`
to the policy it serves, carrying the arm's nonce, and the rig records what the browser reports. The
override arm's precondition is a `script-src` report from this arm's frame, waited for under
`ctx.signal` and ending in the diagnosis. Measured on both engines: a widened frame is reported for
`script-src` and a sealed one never is, while both are reported for the image the policy refuses. So
the sealed arm now waits for its own `img-src` report, which turns "no script-src refusal here" from
an unguarded absence into one measured beside a presence.
`report-uri` is additive -- it names where a report goes and changes nothing about what is enforced
-- and the first case now pins that by splitting the served header and asserting the rest is the
shipped Kotlin text exactly.
The in-frame collector stays, for the diagnosis only, and it now carries the readings that would
have answered the ordering question in one run: the init script records when it ran in each frame,
the artifact's script records the same on the document element, and the diagnosis prints both. What
the artifact wrote moved off `window` entirely for the same reason -- a page init script owns the
window of every frame it reaches. Locally the init script precedes the artifact's by one
millisecond, in every arm on both engines; the ordering on Chrome 152 is now a reading rather than a
hypothesis.
A measurement worth keeping beside the code: in a frame with no `allow-scripts` the init script runs
and its array exists, and no violation event is ever delivered to it, while the browser reports the
same refusals to the server. That is why the old `violations` assertions could not have caught this.
Red-first, all three locally: with report recording off, with the report endpoint not appended, and
with `script-src` reports alone dropped, the preconditions time out into the diagnosis and the
served-policy assertion reds too. 18 of 18 on both engines, three runs.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): cover the navigation wait's sampling branch, and bind the load wait to the case
Two findings from the bots on the rig, both real.
The navigation wait's five-second sampling branch called `describePreviewFrame` after the import
that supplied it had gone. It fires only when an arm is slow, the name is evaluated before `.catch`
can attach, and `no-undef` is off, so nothing in the file or the lint run had ever executed that
line. Fixed by moving the settle waits into the readiness module, where the call sits beside the
import it needs rather than a file away from it -- the split is what let the reference dangle.
The proof is a case that drives the branch: a navigation the arm will never see, a sampling interval
passed in, and the case's own abort ending it, asserting on the reading it printed rather than on
its own absence of an error. Red-first, with only that branch's callee renamed: 2 failed, 18 passed,
`ReferenceError`. So the case covers the branch and nothing else in the file did.
The load-only arm's `frame.waitForLoadState('load')` was the one wait left that did not observe
`ctx.signal`; after an abort it kept waiting on its own timeout. It is a main-world poll on
`document.readyState` now, re-resolving the frame each attempt like every other wait here, and it
ends in the diagnosis.
20 of 20 on both engines, twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The addon gate already rejects a conpty.node without the L"msys-2.0.dll"
marker, in the Electron probe and after the rebuild. But the rebuild compiles
whatever node_modules/node-pty holds, and pnpm only materializes that from the
patch at install time. On a Windows dev checkout whose node_modules predated
the denial, --force compiled for minutes, rewrote conpty.node byte-identical
and unpatched, and the gate then advised "rebuild from source" -- the step
that had just run.
Read src/win/conpty.cc before compiling. If it lacks the literal, stop before
the rebuild and say to run pnpm install, which re-applies the current patch.
An absent source file is not judged; the addon gate still reads the binary.
* refactor(mobile): move the review route body onto a component and the handoff seam (OTA phase C, C4.4)
The review route file called `useMobileDiffReviewController` at its top level. A switch cannot
keep it there: hooks are unconditional, so the whole controller — its client subscriptions
included — would run behind the shell's page whenever the shell renders. As an element passed for
`fallback` it is created and not mounted, which is how the explorer switch already behaves.
`useRouter` becomes `useRouteHandoff` in the same move. It was the one raw expo-router router left
in the review closure (measured: the only other value import of one is the seam's own web sibling),
and inside the page the session screen `openSession` replaces to is native, so that target has to
be handed back to the app rather than posted into a document that does not render it.
The params are read in the component rather than handed down, so this is the route body and the
route file above it is free to become a switch.
`session-router-seam-census.test.ts` gains the module by name. Kept with `useRouter` the census
reds twice — `imports nothing from expo-router that can navigate` names
`MobileDiffReviewRouteScreen.tsx (useRouter)`, and the completeness case gains `useRouter` — which
is what forces the swap rather than leaving it to a reviewer.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): switch the source-control and review routes to the shell, still unregistered (OTA phase C, C4.4)
Both take the files switch's shape: `firstParam`/`firstReviewParam` on every param, `shellScreenRoute`
as the one predicate, `MobileWebShellScreen` keyed on `shellScreenRouteKey`, the native screen built
as an element and passed for `fallback`. Both gain a `.web.tsx` sibling for `index.web.tsx`'s reason —
the native file reaches OrcaMobileWebShellView, whose module throws at import in a browser, and the
route manifest imports every route.
Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists it,
which is the next commit; until then the flag is the only thing that changes and it is off.
Query params are omitted rather than sent empty, and the whole record is omitted when none was
named: `tab=` is a lens named nothing and lands on `changes` through a different branch than an
absent one, and the same holds for `name`, `origin`, `scope`, `file` and `area`.
`pr` and `history` are deliberately not switched. Both are `Redirect`s into `source-control`, and a
redirect inside the page would leave the session bound to a pathname the page has left; left native
they replace into this route and its switch mounts the shell.
Three censuses red without their rows, measured on this tree:
- `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` names the two new
siblings; `states a reason for every override` reds on a placeholder under 20 characters.
- `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no
others` reds without the two `SWITCHED_ROUTES` names.
- `shell-screen-route-census.test.ts` `walks the route tree and finds them` reds without the two
switch names.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): register the source-control and review page routes (OTA phase C, C4.4)
Two entries in `MOBILE_WEB_PAGE_ROUTES`, five grants each, with the reason for each grant read off
the screen that needs it. The two lists are equal on purpose: the hub's changed-file rows push
review and review replaces back, and a target declaring no more than its opener is a hop the
handoff keeps inside the document. Registering either alone would have put a native frame and a
second bridge session between a changed-file row and its diff.
`pageRouteGrants` is derived from this list, so the two rows are a consequence of the entries and
there is no second table to edit. `pr` and `history` stay native redirects and are never listed; the
derived target list at this tree is [files, files/preview, source-control, [p], accounts,
agent-history, review, session, tasks, web], with no `pr` or `history` row, because the census reads
call sites and both redirects name `source-control`.
Measured on this tree, not carried from the draft:
- The hop census goes 8 -> 16. The eight new rows are exactly `{/h/[hostId], agent-history,
files/[worktreeId], files/preview} -> {source-control, review}`, each handed off for
`native.clipboard.write` and the first four also for `externalLink`. `source-control <-> review`
is absent in both directions, which a new case now asserts as grant-list equality rather than as
the absence of a row — absent is also what an unregistered route looks like.
- The Back census now walks six trees and finds 8 controls, both rules printing empty. The two new
ones are `MobileSourceControlHeader.tsx:46 role=button label=Back to session` and
`MobileDiffReviewHeader.tsx:48 role=button label=Back`, which is what C4.3 bought. The
`ARRIVING_SCREENS` describe it wrote for this moment is removed: with the rows in
`PAGE_SERVED_SCREENS` its trees are covered and its cases were a second reading of the same thing.
- Both closures reach the haptics seam, so `haptics` is declared by measurement: the seam census
derives the reaching set and its two cases pass with the routes in its `ROUTE_MODULES` map.
Without the two manifest entries these red on this tree: `pins every hop the handoff must take away
from the page`, `keeps the hub and review local to each other`, `declares only routes the bundle has
a module for`, `reaches the built manifest`, `covers every page route and finds a control in each`,
and both haptics-seam cases.
`build-mobile-web-app-bundle.test.mjs` is split rather than fenced. The two pinned entries put it at
607 non-comment lines against the 600 cap, and the declaration block is a different concern from how
the bundle is built — it grows once per registered domain while that file does not. It moves whole
into `mobile-web-page-routes.test.mjs`, named for the module it is written against, so the next route
to register does not have to choose between a lint fence and a split it did not ask for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): render-check the two page routes, and make the oversized stage-all readable (OTA phase C, C4.4)
The render check mounts both routes in a real browser, asserts each paints its own screen rather
than the Unmatched route with no console error and no page fault, and asserts each fetches its own
chunk on a client-side navigation. It also reads the shipped `img-src 'self' data:` out of the
Kotlin source it is served with, pins the Swift twin beside it, and asserts neither route leaves the
origin or logs a policy violation while it paints.
The avatar skip itself (ruling 3) is `PRCommentCard`: on web it renders its existing empty-avatar
`View` rather than letting one `<Image>` per comment attempt a fetch the policy refuses. Its branch
is pinned by a component test, which reds on the platform check being removed. The render check's
off-origin case is honest about being the negative half only — no comment card renders there,
because the PR chain behind it is not scripted, and the file says so.
The `useAnimatedScrollHandler` risk is answered by the two static facts rather than by a probe, and
they are recorded as assertions: the hook is deliberately outside the four `MAPPER_HOOKS` because it
is an event handler, and its updater's only effect is a write to `scrollOffsetY`, which
`RightDrawer.tsx` assigns in two places and reads in none. A later read reds that case the moment it
is added.
The `oversized` stage-all refusal (ruling 2, made testable by ruling 5) was a silent no-op, and this
is the fix as well as the case. Measured on this tree before it: `git.bulkStage` with 12,000 paths
posts one 1,033,012-byte frame, the shell's reader drops it with `{ kind: 'refused', refusal:
'oversized' }`, and the page's promise never settles — `busyAction` never cleared and
`setActionError` was never called. Both new cases red by timing out at 15s against that path.
Refused at the page's own send boundary instead, under the shell reader's own predicate rather than
a second spelling of it: `isBridgeFrameWithinCap` is extracted from `parseBridgeMessage` and used by
both sides. `sendFrame` answers `sent` / `oversized` / `port-failed`, so `sendRequest` rejects with a
`BridgeRequestOversizedError` whose message is a sentence the panel puts on its error surface, and
the members whose contract is a boolean keep it. No delivery-unknown mark: the frame never left, so
nothing ran on the desktop and the smaller retry is safe to offer.
The case runs the real chain — bridge port pair, `useMobileGitRequests`, `runGitWorkflow` — with
only react-native and the haptics seam mocked, and asserts the message that lands is a sentence and
that the busy flag is raised and then cleared.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the type assertions from the two new C4.4 test files (OTA phase C, C4.4)
The changed-code quality gate named five, all in the files the previous commit added, and a fence is
not the answer to any of them. A separate commit because a reported head does not move by amend.
- The comment fixture is a real `PRComment` rather than a cast: the type's six required fields are
all this case needs, and the SAFETY disable that stood in for them was inert anyway — oxfmt had
wrapped it onto three lines, and a wrapped `oxlint-disable-next-line` matches nothing.
- The image lookup goes through `findAll` on the host tag rather than `findAllByType`, which takes a
component. Through `String`, because `node.type` is `ElementType` and React Native declares no
intrinsic elements, so the compiler reads a bare tag comparison as unreachable.
- The runners hook takes its router from `useRouteHandoff` with expo-router mocked under it, which
is how a `RouteHandoff` is obtained rather than asserted into existence. No target is pressed.
- The rejection and the diagnostic are read through narrowings instead of casts, which also drops
an `expect.any` that only type-checked because of one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep a frame the page cannot serialize inside the send contract (OTA phase C, C4.4 round 1)
Round 1 finding 1. The oversized refusal moved `JSON.stringify` outside `sendFrame`'s `try`, so a
frame carrying a cycle, a `BigInt` or a throwing `toJSON` threw past the whole send path. Three
things followed, all measured here on a cyclic `params`:
- the caller was rejected with a bare `TypeError` from `JSON.stringify` instead of the
`BridgeSendFailedError` every other undelivered frame raises;
- no `send-failed` diagnostic was raised, so nothing recorded that a frame had been lost;
- `sendRequest` opens the id before it posts and abandons it on the way out, and the throw skipped
the abandon: 63 of the 64 in-flight slots were usable afterwards, against 64 on a client that sent
no such frame. Sixty-four of them and every later request is refused with nothing to say why.
`posted()` carried the same escape into the members whose contract is a boolean, where a throw is
worse still: those callers are taps and teardowns with no catch on them.
Serialization goes back inside the `try`, with the oversized refusal kept in front of the post. The
docstring said the port arm's throw is never `JSON.stringify`'s, which was exactly the assumption
that broke; it now says why the call sits where it does.
The new file is the pin: the rejection's name, the diagnostic, nothing reaching the shell, and the
slot count with a no-cyclic-frame control beside it so the count cannot pass by the cap moving. Both
changed cases red on the serialization moving back out — `expected 'TypeError' to be
'BridgeSendFailedError'` and `expected 63 to be 64`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the page's frame cap to the reader's, at the boundary and by construction (OTA phase C, C4.4 round 1)
Round 1 finding 2. Nothing held the sender's predicate to the reader's. Replacing
`isBridgeFrameWithinCap(json)` with an inline `json.length > BRIDGE_MAX_MESSAGE_BYTES + 1` passed 85
of the 86 mobile-web-shell and source-control test files on this tree, and a frame at exactly cap+1
would then be posted and silently dropped — the hang the refusal exists to end, back for every frame
in that one-unit band.
Two rules, because either alone passes against the defect:
- The boundary. A frame of exactly the cap is posted, arrives at `parseBridgeMessage` and is
accepted; a frame one byte over is refused with `BridgeRequestOversizedError`, posts nothing, and
is the same string the reader answers `oversized` to. An off-by-one reds the second.
- The census. The client reaches the cap through the shared predicate and does not name
`BRIDGE_MAX_MESSAGE_BYTES` at all, and the module that exports the predicate is the module that
parses inbound frames. A private copy that is correct on the day it is written reds here.
The overhead the boundary frames are built from is itself checked rather than trusted: a frame asked
for at exactly the cap must serialize to exactly the cap, so the constant cannot rot behind an
envelope that grew a field.
Against the mutation both new rules red — `expected null to be 'BridgeRequestOversizedError'` and
the census failing to find the predicate — while the rest of the suite stays green, which is the
finding reproduced.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): count the outbound frame in the unit both shells count it in (OTA phase C, C4.4 round 1)
Round 1 finding 3. Read off both shells rather than assumed, and they agree: iOS gates the inbound
frame on `json.utf8.count` (`MobileWebShellView.swift`, through
`MobileWebShellBridge.acceptsByteCount`) and Android on `json.toByteArray(Charsets.UTF_8).size`
(`MobileWebShellView.kt`, through `acceptsMobileWebShellBridgeByteCount`), both against `640 * 1024`.
UTF-8 bytes on each platform.
The predicate was already right. `isBridgeFrameWithinCap` decides on `utf8ByteLength`, and the
`raw.length` clause in front of it is a cheap refusal in the safe direction, not a second rule: every
code unit encodes to at least one byte, so a string over the cap in units is over it in bytes too.
The diagnostic was not. It reported `json.length` — UTF-16 code units — in a field named `bytes`, so
a frame of CJK text read as a quarter of the cap at the moment it was refused by it. It now reports
`utf8ByteLength(json)`, and the type says which unit that is.
Pinned with a 250,000-character frame of three-byte characters, which is under the cap in code units
and over it in bytes, plus a source case reading the measuring expression out of each shell. Three
mutations, all red: dropping the byte clause from the predicate reds the refusal (`expected null to
be 'BridgeRequestOversizedError'`) and the diagnostic; reporting `json.length` again reds the
diagnostic alone (`expected 250094 to be greater than 655360`), which is the defect this commit
fixes, in the number it would have printed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): drop the render check's avatar assertion, which could not fail (OTA phase C, C4.4 round 1)
Round 1 finding 4. The case asserted that no avatar host was requested while both routes painted,
which reads as a proof of the web skip and is not one: no comment card renders on either page,
because the PR chain the file's own closing note names is not scripted. Reproduced here — deleting
the `Platform.OS !== 'web'` guard from `PRCommentCard` leaves the file at 5 passed.
Deleted rather than propped up. Giving the page a presence precondition means five hand-written
fixtures against five Zod schemas inside the shell double, which is exactly what the harness's
docstring says that double must not become. So the only proof of that branch is
`pr-comment-card-web-avatar.test.tsx`, which reds when the check is removed, and the render check now
says so in its header instead of implying otherwise.
What survives is a property of these two closures rather than of that component: not one request
leaves the origin while either route paints, and nothing either paints violates the policy. That one
can fail — planting a `fetch` to a provider host in a module both routes reach reds it twice, on the
console-error case and on the off-origin case, with the `connect-src 'self'` refusal in the output.
The CSP half is unchanged and was never in question: the served header is read from the Kotlin source
and the Swift twin is pinned beside it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* chore(mobile): stop ignoring the retired document-factory artifact
C7.5c (#21878) retired the generator that emitted
src/terminal/terminal-webview-document-factory.generated.ts, but the
.gitignore line stayed. In any tree that ever built it the orphan stays
invisible to `git status`, and terminal-webview-consumer-census.test.ts
reads it as source and reds by name. Unignored, it shows as untracked and
the ordinary stray-artifact cleanup removes it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* chore(mobile): drop the retired document-factory artifact from oxlint's ignore list
pullfrog on #21962: the same retired path was still listed under ignorePatterns
in mobile/.oxlintrc.json. Inert (the file no longer exists), removed for the same
reason as the .gitignore line.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the terminal WebView document byte for byte
The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.
So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.
The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.
Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.
Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare two terminal documents as programs, not as bytes
The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.
It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.
So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.
Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.
Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count each normalisation the move makes, separately
Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.
The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.
Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.
Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.
Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the mouse-report cell a module the page can import
The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.
The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.
Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.
`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.
The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.
The document is untouched, so the byte pin from 3006d8dfdf is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the query-reply gate a module the page can import
The second of the twelve groups, and the one that corrects the scope table's
membership rule.
`terminalDataRepliesEnabled` is written from four places, so the whole-script
census counted it among the 57 variables that cannot stay free across modules.
All four writes are in this group. Once the script is modules, a variable
written only inside the module that declares it is that module's own state, not
the document's, and it stays a `let` there. So the scope object holds what
crosses a module boundary, and the 57 is an upper bound rather than the answer;
the qualifier count the flip commit pins will be lower than the 641 measured
over the single scope, and by how much is a function of where the boundaries
fall.
Two references do cross here and are qualified: the write-queue generation this
group compares against, and the observer-disposal list it pushes onto.
Counts pinned: two qualified references, one `var` to `let`, two one-statement
`if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make reflow a module, and give the generator its own tests
The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.
The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.
Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the keyboard-avoidance metrics a module
The fourth group, and the first that needed a non-null assertion.
`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.
Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.
Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.
The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make WebGL loss recovery a module
The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.
The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.
Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make indirect-pointer scroll a module, and count a fifth class
The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.
Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.
The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.
The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.
Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal surface-swap group into a module
The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.
Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): substitute build-time constants into the emitted document
The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.
The fixture script's TypeScript loader moves beside it rather than being
written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal theme group into a module
The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.
Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.
Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal path-tap group into a module
The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.
Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.
One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.
Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal tap-dispatch group into a module
The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.
Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal mouse-click-drag group into a module
The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.
Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal url-tap group into three modules
The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.
Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.
Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.
Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-mode DECSET scan slice into a module
The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.
Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal message-bridge slice into a module
The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.
The second catch keeps its binding: it names the error and reports it.
Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give the document close its own slice file
The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.
Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal term-observers slice into modules
This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.
A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.
Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-state-and-eviction slice into a module
The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.
Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the smooth-scroll and cell-geometry slice
Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.
Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal write-queue slice into a module
The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`,
which belong to other concerns but sit here because emitted-document order
pins them here; four names stop being externals as a result.
The observer disposal keeps its guard-as-expression form behind a line-scoped
disable: the rewrite the rule asks for is outside every counted class.
Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10,
unbound catches 1, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal fit-scale slice into a module
The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.
Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal init-and-write slice into a module
The slice opens with the already-extracted webgl-recovery group, so its text
is two emissions joined. init() resets almost every field the document shares,
which makes this the densest qualifier site in the script.
The caret options were interpolated from the theme module, so they join
`document-constants.ts` as four exports: a substitution is keyed by name, not
by property path.
One local the document declares and never reads keeps a line-scoped
`no-unused-vars` disable. Removing it would be a different program, which is
the one thing this branch does not do.
Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18,
unbound catches 7, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the runtime-state and text-scaling slice
The document's declaration block, where almost everything it shares is
declared, with the query-reply and surface-swap groups interpolated inside it.
Three modules: the two declarations that come before the groups, the text
scaling, and the viewport transform with the scroll indicator. Seven more
names stop being externals.
Two things this slice forced.
The scope-declaration rule now counts each declarator of one `var`, because
`var panX = 0, panY = 0` becomes two assignments onto the scope. It has its
own acceptance case in the instrument's test.
The two halves are compared against their own text rather than as one joined
program. The declaration the slice opens with is shadowed by a parameter
inside one of the interpolated groups, and printing the baseline as one
program renames that parameter; qualifying the outer name removes the shadow,
so the rename has nothing to correspond to. Splitting the slice on the group
constants compares like with like, and those groups have their own tests.
Build-time constants are now substituted textually rather than through an
esbuild `define`: a `define` whose value is an object or an array is injected
as a helper binding instead of being inlined.
Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38,
rebindings 25, braced bodies 13, unbound catches 1.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the two test files the last commit left unformatted
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-report and scroll-routing slice
Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.
Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the host-message-router slice into modules
Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.
The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.
Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-overlay slice into modules
Two modules after the already-extracted path-tap and url-tap groups: the
selection range with the xterm mirror, and the overlay positioning with the
edge scroll. Six more names stop being externals, which leaves one.
Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63,
unbound catches 9, number properties 6, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the surface-touch-gestures slice into modules
The last of the thirteen slices. Two modules after the three already-extracted
groups: the selection menu's buttons, and the touch gestures with the pinch
and the momentum scroll. `attachSurfaceEventHandlers` was the last external,
so `document-externals.ts` is gone: every name the document uses now resolves
to a module.
The instrument reads both sides strict. A loose script has to defend Annex B's
block-scoped function declarations, and the printer does that by hoisting a
`var` and renaming the function, so one side carried a rename the other could
not. Neither name escapes its block, so the two readings agree on behaviour
and only the strict one can be compared. It has its own acceptance case.
Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57,
unbound catches 2, number properties 2, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the document's opening declarations into a module
The document shell carried the IIFE opener and the eight declarations inside
it, so it splits the way the message-bridge slice did: the shell keeps the
HTML and the opener, a new slice file holds the declarations, and the byte pin
proves the join is unchanged.
With this every line of the document's script has a module behind it.
Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the whole document script against the modules
Every line of the script now has a module behind it, so the whole thing can be
compared at once. This is the review of the move, as one number per class:
qualifier 609 references + 73 declarations = 682 sites
var rebindings 373, the document's 446 declarators less those 73
curly braces 279, the number measured before any of this started
unbound catches 36 of 38; two name their error and report it
Number properties 17, also measured up front
shorthand properties 4, two SGR flags written twice each
unshadowed names 7
A seventh class was needed and is counted like the others: a binding that
shadowed a document variable stops being a shadow once that variable moves
onto the scope, so the printer stops disambiguating it. It has its own
acceptance case.
The module order lives in one file that both this test and the generator read,
so neither can drift from the other.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): keep only the lint directives that do something
Seventeen of the disables were inert: `typescript/no-non-null-assertion` is
not enabled here, and a directive naming two rules on one line is not parsed
at all, so the one rule that did apply was being ignored too. The changed-code
quality gate reports an inert directive as a finding.
The two that matter are back, one rule per line: the guard-as-expression in
the observer disposal, and the local the document declares and never reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): generate the terminal document from its modules
The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.
The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:
qualifiedReferences 609
scopeFieldDeclarations 73
rebindings 373
bracedBodies 279
unboundCatches 36
numberProperties 17
shorthandProperties 4
unshadowedNames 7
Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.
Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.
Pattern changes, old -> new.
terminal-webview-scroll-routing.test.ts
var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y;
smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
-> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break
}, { capture: true, passive: false }); -> { capture: true, passive: false }
function momentumStep() -> let momentumStep = function()
pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame(
pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0;
cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId);
var writeQueueHead = 0; -> scope.writeQueueHead = 0;
writeQueueHead++; -> scope.writeQueueHead++;
writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX
getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale("
var FRICTION = 0.972; -> const FRICTION = 0.972;
var MIN_VEL = 0.012; -> const MIN_VEL = 0.012;
edgeScrollDir = dir; -> scope.edgeScrollDir = dir;
term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir);
// Latching document-level touch dispatcher -> function attachSurfaceEventHandlers(
edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX;
edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY;
return mode !== 'none'; -> return mode !== "none";
var pixelX = cell.x; -> const pixelX = cell.x;
var pixelY = cell.y; -> const pixelY = cell.y;
...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) {
...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) {
if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress;
if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press;
if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return "";
document.addEventListener('touchend' -> document.addEventListener( "touchend"
}, { capture: true, passive: true }); -> { capture: true, passive: true }
notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
document.addEventListener('touchstart' -> document.addEventListener( "touchstart"
var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });
terminal-webview-text-zoom.test.ts
var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT =
var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
\n\n function enqueueWrite -> \n function enqueueWrite
var terminalFontFamily = -> scope.terminalFontFamily =
output = terminalFontFamily; -> output = scope.terminalFontFamily;
String.fromCharCode(0x23fa) -> String.fromCharCode(9210)
TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2)
term.open(surface) -> scope.term.open(scope.surface)
term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11"
enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData)
fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily
fontWeight: '300' -> fontWeight: "300"
fontWeightBold: '500' -> fontWeightBold: "500"
terminal-webview-engine.test.ts
var webglAddon = null; .. var webglRecoveryTimer = null;
-> the refreshTerminalSurface()..init( block, with the scope preamble
window.addEventListener('resize' -> window.addEventListener("resize"
'terminal init failed' -> "terminal init failed"
'terminal message failed' -> "terminal message failed"
var everReady = false; -> scope.everReady = false;
everReady = true; -> scope.everReady = true;
fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal
msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady
/fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/
if (msg.type === 'ping') -> if (msg.type === "ping")
notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id })
terminal-webview-reflow.test.ts
} else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2)
var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20;
if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width' -> flog("measure-skip-small-width"
notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... })
var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle"
window.addEventListener('message' -> window.addEventListener("message"
terminal-keyboard-avoidance-webview.test.ts
\n // reflow() -> \n function reflow(
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
\n var panX -> \n scope.panX
TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document
terminal-webview-query-reply.test.ts
attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2)
term.attachCustomKeyEventHandler(function() { return false; })
-> term.attachCustomKeyEventHandler(function() { \n return false; \n });
term.textarea.readOnly = true -> term.textarea.readOnly = true;
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
terminal-webview-url-tap.test.ts
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): delete the slice constants and injected fragments
The document is generated from its modules now, so the strings it used to be
pasted together from are dead. Deleted: the fourteen slice constants under
`terminal-webview-html/` (host-message-router, message-bridge,
mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants,
runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-touch-gestures,
term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write,
write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`,
`document-close.ts` and `theme.ts` stay: the shell and close are still the
document's HTML, and `theme.ts` is where `document-constants.ts` reads the
palette from.
Ruling 17, second commit. Tests that asserted the extraction mechanism itself
went with it: they compared one module's emission against the slice text it was
extracted from, and the flip test now pins the whole document against the whole
pre-flip script with the same eight classes. Deleted, all under `document/`:
fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge,
mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing,
mouse-report-cell, path-tap, query-reply, reflow, runtime-constants,
runtime-state, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures,
tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery,
wheel-scroll. `document/url-tap.test.ts` stays: it pins against
`URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and
still has a consumer.
Tests that asserted behaviour through a deleted string now read the generated
document. `document/generated-document-region.test-support.ts` is the one way in:
`documentScopePreamble()` returns the scope object the document opens with, and
`generatedDocumentModule(name)` re-emits a module and refuses unless the document
carries that text verbatim, so an evaluated block is the WebView's own bytes. The
two local copies of the preamble in the engine and text-zoom tests were folded
into it.
Moved, with every assertion kept and the `expect` count per file unchanged:
terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34
terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14
terminal-webview-query-reply.test.ts 14
terminal-path-tap.test.ts 25
terminal-webview-url-tap.test.ts 33
terminal-keyboard-avoidance-webview.test.ts 18
terminal-webview-reflow.test.ts 22
terminal-webview-text-zoom.test.ts 59
terminal-webview-engine.test.ts 49
Pattern changes, old -> new.
terminal-webview-reflow.test.ts
if (!term || isAlternateBufferActive()) return;
-> if (!scope.term || isAlternateBufferActive()) {
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
var wasAtBottom = buffer.viewportY >= buffer.baseY;
-> const wasAtBottom = buffer.viewportY >= buffer.baseY;
term.scrollToBottom(); -> scope.term.scrollToBottom();
if (nextCols === term.cols && nextRows === term.rows) return;
-> if (nextCols === scope.term.cols && nextRows === scope.term.rows) {
The other eight files kept their patterns; only the text they read changed, from
a deleted constant to the document block. The harnesses that evaluate a block now
build the document's scope object instead of declaring the vars it replaced, and
hand the terminal in as `scope.term`.
Controls, one per file: the module line an updated pattern guards was removed,
the document rebuilt, and the test run. All red, and the tree restores green.
query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed
path-tap const parsed = parsePathLineCol(...) -> path-tap test, red
keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed
osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed
terminal-theme scope.term.options.minimumContrastRatio = ...
-> theme test, 4 failed
write-queue scope.writeQueue[scope.writeQueueHead] = undefined
-> write-queue test, 4 failed
`document-scope.ts` docstrings named the slice each field belonged to; they name
the owning module now. Three module comments pointed at deleted injected files
and point at the modules instead. Neither changes the document: esbuild drops
comments, and the byte pin is unmoved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the right number of counted classes
The flip test's title still said seven; the table it asserts has eight.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the shape applyTerminalTheme writes through
The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test.
`applyTerminalTheme` touches exactly two slots on the terminal it is handed, so
`terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and
the test's parameter and both fixtures use it. The theme is optional on the way
in because `applyTerminalTheme` is what writes it.
No cast. The type is erased by the generator's transform, so the document is
unchanged and the flip test's class table and the byte pin both still hold.
Control: restoring the `object` parameter reproduces the finding at
terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type
it exits 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip pin, leaving the byte golden as the document's fence
`terminal-document-flip.test.ts` compared the emitted modules against
`terminal-document-pre-flip-script.txt`, the hand-written script as it stood before
C7.1, and held exactly while no module changed. That is the proof of the flip, not a
standing fence: the first lane that must change a module has to retire it or restate
its counted classes for a reason that has nothing to do with the move.
C7.5 is that lane — the document's host seams become scope fields so the page can set
them — so both go here, while the test is still green. The flip proof lives at
51ae7b1b03 ("test(mobile): name the right number of counted classes"), which is where
anyone reviewing the move should read it.
From here the standing pin is the whole-document byte golden,
`terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by
the payload-hash digest beside it. Regenerating it is a review event: the emitted diff
is listed old to new in the commit message and in the PR body, and a golden that moves
without a listed diff is a blocking finding.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the terminal document's host seams a field on its scope
Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a
terminal `notify` through it would post raw terminal JSON into the bridge's channel,
and there is no engine IIFE hanging `Terminal` and the two addons off `window` because
the page imports xterm. Four reads had to become seams:
host-notify.ts notify() -> scope.postToHost
viewport-transform flog() -> scope.postToHost
terminal-init.ts new Terminal(...) -> scope.createTerminal
terminal-init.ts window.Unicode11Addon-> scope.createUnicode11Addon
webgl-recovery.ts window.WebglAddon -> scope.createWebglAddon
Each default is the window read the site already did, still performed at call time and
not captured when the scope is built, so inside the WebView the program is the one it
was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object,
because the scope's defaults are those functions and the factory runs as the script is
parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's
file, which the four fields pushed over the 300-line cap; document-scope re-exports
them, so no importer moves. The page's side of the seam lands in C7.5's later commits.
Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside
the `try` it was built in, so a constructor that throws is still swallowed; and no
WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`,
which is the DOM-renderer fallback rather than a failure.
Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document
723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else:
+ (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } }
+ (new) function createEngineTerminal(options) { return new Terminal(options); }
+ (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; }
+ (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; }
- " pendingTerm: null"
+ " pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon
- flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }"
+ flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });"
- " if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {"
+ " if (!scope.term) {"
- " addon = new window.WebglAddon.WebglAddon();"
+ " addon = scope.createWebglAddon();" then " if (!addon) {" / " return false;" / " }"
- " scope.term = new Terminal({"
+ " scope.term = scope.createTerminal({"
- " if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / " try {" / " scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / " } catch {"
+ " try {" / " const unicodeAddon = scope.createUnicode11Addon();" / " if (unicodeAddon) {" / " scope.term.loadAddon(unicodeAddon);" / " } catch {"
- notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }"
+ " scope.postToHost(msg);"
Nothing else in the document moved: the emitted indentation, statement order and every
other literal are byte for byte what they were.
Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the
new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions
and aimed them where the text now is: `window.WebglAddon.WebglAddon` and
`new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on
the recovery module, and the recovery module is asserted to call
`scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope
before the globals exist to show the defaults read the window when they post, shows
each addon factory answering null when the engine has none, and drives a host message
in and a notify out with all four fields set, asserting the bridge is never touched.
Red before this commit at 6 of 7 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): write the xterm stylesheet as its own generated artifact
The page mounts xterm itself, so it needs the engine's stylesheet and must never
resolve the engine string: 612 KiB of minified IIFE built to be injected as text into
a WebView document, unusable under the shell's `script-src 'self'` with no nested
frame to load one into, and the largest single module the session route's closure
would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of
the CSS pulled the string in behind it.
`build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts`
beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment
strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside
its neighbour and written by the same postinstall step, so a fresh tree gets both or
neither. `document-shell.ts` takes the CSS from the new module and the engine string
from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold
both constants read them from their new homes.
The document did not move: `terminal-document-golden.txt` is byte for byte what the
last commit left, 105,968 bytes, and the payload digest is unchanged.
The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks
every module under `src/terminal/document/` as an entry point — the document is one
script whose modules reach each other by side effect, so no single one of them roots
a graph holding the rest — and asserts the engine string is in none of their closures,
with two modules named as the precondition that the walk resolved anything at all. The
native document's own closure is asserted to still hold both generated modules, so the
first case cannot pass by the CSS having gone missing. And the third case plants a
document module that imports the engine string in a scratch tree and shows the walk
reports it, which is what makes the absence above a measurement.
`mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes
the entry points and an optional working directory; the route closure's own two entry
points and its extensionless-specifier reason are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the dead URL-tap constant and two stale reflow guards
Round 1 fixes, all three folded here.
1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with
`document/url-tap.test.ts` deleted alongside it. The document is generated
from its modules now, so that constant was a second copy of the URL-tap group
with no consumer but its own tests. terminal-webview-url-tap.test.ts's
resolver harness reads the document's own text instead, the path-tap,
url-tap, osc-link-tap and surface-tap modules in document order through
`generatedDocumentModule`, which refuses unless the document carries each
verbatim. Its 33 expects all stay. One mechanism-only assertion went with the
file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts`
pin of the three emissions against the constant, which the flip test's
whole-document pin already covers. The file's other exports stay.
The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts
concatenated terminal-webview-url-tap.ts into its `source`, and its
`notify({ type: 'terminal-tap' });` assertion was matching the constant's
single-quoted text, not the document. The read is dropped, since nothing else
in that file needed it, and the assertion is the document's form:
notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" });
Its 95 expects stay. Leaving the read in place would let a document assertion
pass against a module source, which is the hazard this lane exists to remove.
2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer
exists, so it could not fail:
expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
-> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)
Same intent against the generated document: the reflow module's emitted text
is in the document exactly once. The case is renamed to say so and the
comment above it describes the generator, not the deleted template.
3. Same file, the routine assertion still passed as a substring of the qualified
call; qualified as line 30 already was:
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
Its 22 expects stay.
Controls, each verified to have changed the file first, all red, tree green
after restore:
osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed
surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
module order 'reflow' listed twice -> reflow test, expected 2 to be 1
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): mount the terminal document in the page instead of a WebView
`react-native-webview` has no web build that renders anything: measured, it paints the
line "React Native WebView does not support this platform" where the terminal was. So
the page mounts the document itself — xterm imported from `@xterm/xterm` with the
unicode11 and webgl addons, and the document's own modules imported in the order the
generator emits them — behind the identical `TerminalWebViewProps` and
`TerminalWebViewHandle`.
Written as one implementation, not two. `use-terminal-webview-controller.ts` is
everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the
readiness handshake, the pending queue, the write coalescer, the notify dispatch and
the whole imperative handle. Its two arguments are the difference between the hosts —
a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to
re-prove the document with a ping. The native component posts across the bridge and
answers yes on iOS; the web component calls `handleMsg` and answers no, because its
document is the page's own modules and there is no second content process to lose. A
second copy of that file is the fork the series exists to avoid, since the handle is
the contract every consumer holds.
`terminal-webview-ready-promises.ts` carries the two promises the handle hands out,
`awaitReady` and `measureFitDimensions`, which the controller's length made a module.
`document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out
of the document shell; the shell composes them and the golden is byte for byte
unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the
caret options and nothing else, so the page resolves no document string and no engine
string.
`terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the
stylesheet and the markup, sets the four scope seams, and reaches the modules by one
dynamic import — they read their elements as they are parsed, so a static import would
hoist above the planting and leave every one of them holding null.
`page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19
because on the page those `message` frames belong to the shell; its one non-bridge
duty, the window-resize refit, is re-armed by the mount.
`page-document-module-order.test.ts` holds that list against the generator's own,
so a sorted import list or a module added on one side cannot pass.
Two page-side degradations, both bounded and both stated. The document assigns
`window.onerror` as it is parsed, so while a terminal is mounted page errors reach its
reporter; the mount restores the previous handler on dispose. And a browser that
refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery`
already has for a context loss, with a `[fit]webgl-unavailable` notify saying so
rather than a silent halving of the drain rate.
`terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it
scans `src/session` and the terminal directory for an import of the component file by
name, of `terminal-webview-html`, of either generated engine module or of anything
under `document/`, finds none outside the component and its mount, and shows on
planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs`
gains the component's own closure: `TerminalWebView.web.tsx` and
`terminal-webview-html.web.ts` are in it, the engine string, the native HTML module
and `message-bridge` are not.
Four source greps follow the code into its new home, every assertion kept:
`terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the
controller, and reads the two lifecycle clears once in `resetReadiness` plus both
WebView callers in the component; `terminal-webview-reflow` and
`terminal-webview-scroll-routing` read the handle in the controller and the two timers
in the promises module (`measureResolveRef.current === finish` -> `measureResolve ===
finish`, `void p.finally` -> `void pending.finally`).
One behaviour was nearly lost and is pinned by an existing case: the native
foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render,
so the transport asks a predicate rather than carrying a boolean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): render the page's terminal in a browser under the shell's policy
Everything below the contract is new on the page: xterm is an import rather than a
612 KiB string in a WebView document, the document's modules run in the page's own
realm, and the elements they read by id are planted by the component. No module test
settles whether that opens at all under `script-src 'self'` with neither
`unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the
buffer intact.
Three cases in the C6 render harness, against the bundle built by the real builder and
served under the policy parsed out of the shell's own Kotlin constant.
The stream is built for the grid rather than committed: an SGR colour change per cell,
an erase-to-end and an absolute cursor position per row, run out past the host's own
48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the
document's own path — select all, then the Copy button the overlay carries — so the
oracle is the component's `onSelectionCopy` prop and not a private reach into xterm:
6,133 characters, both edge markers present, and no escape byte or SGR text left in
them, which is what says the parser consumed the stream instead of printing it.
The second case takes a fit through the handle, which on the page is a command in and
a notify back with no bridge between, and carries design §8's cheap half of the IME
question. It first pins something that changes where that probe can even point:
xterm's own textarea is inert by the document's design — `query-reply.ts` makes it
read-only, untabbable and `inputmode=none` so touch and hardware keys go to the
screen's input — so text entering a terminal on the page arrives at a `TextInput`, and
that is what is typed into. Chrome reports `insertText` with `isComposing` false for
each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft
keyboard is the device step and this does not claim to answer it.
CSP violations are counted with a `securitypolicyviolation` listener installed before
anything else runs, which is stricter than the console-error filter the other render
checks use — and the first thing it found was not the terminal's. The page entry
carries Zod, whose `new Function` probe is swallowed by its own catch, so
`script-src: eval` is refused once on any page route with no page error and no console
line. The first case is the control that names it, on a route that mounts a marker and
no terminal; the two terminal cases subtract it and report zero of their own. Zero
page errors and zero console errors besides.
No route serves this screen until C7.7, so the component is bundled through a scratch
route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx`
exactly as a real route would. That step retires when the session route is registered.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): retire the last module concatenator and guard the order list
Round 2 fixes, all five folded here.
1. Deleted terminal-webview-html-source.test-support.ts.
`readTerminalWebViewHtmlSource()` had no consumers left once the behavioural
tests moved to the generated document, and it was the last thing that built a
document-shaped string by concatenating module sources — its filter admitted
`.test-support.ts` files too, so it could have grown one. Confirmed by grep
that the only occurrence of either name in the repository was its own
declaration.
2. New document-module-order.test.ts asserts both directions: the non-test,
non-test-support `.ts` files under `document/` are exactly
`{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`,
and no name is listed twice. `document-constants` is the one exception
because it is never emitted: its exports are substituted into the modules
that import them as literals, so the document carries its values without
carrying the module. A module added here and forgotten there would be dead
code that reads as live; a name left after its file goes makes the generator
throw at build time rather than at review time.
3. terminal-document-flip.test.ts's docstring now carries the retirement policy
from ruling 18: the test is the proof of the flip and holds only while no
module changes, the first lane that must change one retires it together with
`terminal-document-pre-flip-script.txt`, and the standing pin from then on is
`terminal-document-identity.test.ts`, whose fixture regeneration is a review
event. Comment only.
4. terminal-document-equivalence.test-support.ts said 57 reassigned variables
and "Four classes and no others". It now says 73 declaration sites and eight
classes, with each class's measured figure named. Two doc comments sat above
the wrong declaration and were moved onto what they describe: the
`NUMBER_GLOBALS` one down to that constant, and the printing one down to
`significantTokens`, with `STRICT_DIRECTIVE` given its own line.
5. build-terminal-document-script.mjs substituted constants with
`replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a
constant's value are read as replacement patterns. The substitution is now
`substituteDocumentConstants`, exported so it can be tested directly, and
replaces with a function.
Controls, each verified to have changed its input first, all red, tree green
after restore:
plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module"
drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll"
revert to the string replacer -> 4 failed, "a $& b" became "a marker b"
The `$n` case is deliberately absent from that table: the pattern has no capture
group, so `$1` is already literal under either form and a case for it could not
tell them apart.
The document did not move. The byte golden, the digest and the flip test's class
table are all unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): measure what the page terminal costs the session route's closure
The session route is not served on the page until C7.7, but the closure the bundler
would walk is the same one and the terminal is the largest thing in it. Measured
against this branch's base, `ota-c7-1-terminal-document` at 51ae7b1b03:
modules 4316 -> 4363 (+47)
local modules 927 -> 971 (+44)
minified bytes 3,930,787 -> 3,883,532 (-47,255)
The route gets smaller. It sheds six modules — the native component, the 612 KiB
engine string, the 105 KiB generated document script, the HTML module and the shell
and close around it — all string literals of a program the page cannot run, and gains
fifty: the component, its mount, the stylesheet and markup modules, the two the
controller split made, and the document's own thirty-nine, with xterm and the two
addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts`
is not among them: it declares types and esbuild emits nothing for it.
The census pins the trade in both directions, because "the engine string is absent"
passes just as well on a closure that resolved nothing: the six shed modules are
asserted gone, the eight gained ones and the three xterm packages asserted present,
and the document asserted whole except `message-bridge`, which ruling 19 keeps off the
page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved
styles — since the terminal's modules joining this closure is exactly the change that
could add a tenth unread.
The page-closure families were run before and after on the full corpus, never a
filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879
include the four page-closure pins, which assert the verdict of every golden C1, C2,
C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an
unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed
and 773 skipped. No family moved, which is what an inert lane should show: this
branch changes no RPC, no opcode, no grant and nothing the recorder reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): clear the changed-code gate findings this lane introduced
Eleven findings from `check-changed-code-quality.mjs` against the base, all in code
this lane added, none of them a behaviour change.
Two type assertions lost their directive to the formatter. The xterm `Terminal` cast
sits on the second line of a wrapped arrow body, so a directive above the assignment
aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon
cast had no directive at all. Both keep the same `SAFETY:` rationale on one line,
which is the only shape oxlint reads.
Two more assertions in `host-seams.test.ts` are gone rather than annotated. The
terminal double's `element` is a getter over a local the double's own `open` writes,
and `withSeams` reads each field it is about to overwrite through
`getOwnPropertyDescriptor` instead of indexing the scope with a cast.
Then three `eslint-disable no-console` directives that disabled nothing, an
`oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the
reason it carried stays as a comment, since the dependency list is still deliberate —
and one duplicated `node:fs/promises` import.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): name the closure helper what main already named it
A trial merge against `origin/main` conflicts on this function: main grew the same
generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with
`mobileWebAppRouteClosure` delegating to it and three callers in the page-closure
families census. This branch is based on `ota-c7-1-terminal-document` and so cannot
merge main, but it can stop being a second spelling of the same thing.
Taken over wholesale: main's name, its parameter, its extension stripping and its
comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main
already has. The only addition is an options bag carrying `absWorkingDir`, which the
engine-closure census needs to plant a module in a tree of its own and show the walk
would report it; the real measurements never pass it. What was a whole-function
conflict is now that one hunk.
The census case that measured the native document had named
`terminal-webview-html.ts` with its extension, which main's stripping does not allow.
It names `terminal-webview-html/document-shell` instead — the module that actually
reads both generated ones — which is the better probe anyway and needs no extension
to resolve, since it has no `.web` sibling.
`web-overrides.json` also conflicts and is left alone: both sides append entries to
one list and the resolution is mechanical.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): put the two closure helpers in main's order
The previous commit took main's name and signature but left the route closure below
the module closure, where this branch had written it. Git merged both orderings and
produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint
reports as a duplicated export — a red the trial merge found and neither side's own
lint could.
Same order as main now: the route closure and its docstring first, the module closure
under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the
merged tree lints clean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the flip comparator refuse what it was accepting
Round 2 items 6 and 7, both in the equivalence instrument.
6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving
the two were the same binding, so an unrelated rename ending in a digit would
have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`,
an explicit list of pre-flip name, generated name and declaring module. The
whole script has one entry: `term2` -> `term` in `query-reply`, which is the
`term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven
sites in all. That is stated in the docstring rather than encoded as a second
pin, since the flip test already pins the total.
7. Brace absorption treated every unexpected `{` as a linter-added body and
absorbed any later `}` while one was outstanding, so a bare block anywhere
would have been swallowed. `isBraceableHeadBody` now requires the open to be
the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its
`(` and reading the keyword before it — and `matchingCloseIndex` records the
index the close must appear at, so the absorbed `}` is that body's own.
That check had to move ahead of the equality check. Wherever a braced body
ends a block, the baseline's next token is a `}` as well, so pairing them
would consume the wrong one and leave the counts right for the wrong reason.
Both refusals are tested over snippets:
function f() { return value2; } vs return value;
-> token 6: expected name value2, generated name value
let value = 1; use(value); vs { let value = 1; } use(value);
-> token 0: expected name let, generated {
and the braceable heads are tested one by one, `if`, `for`, `while`,
`if`/`else` and `do`, so the new rule is shown to accept every shape the `curly`
rule produces and not only the one the document happens to exercise.
Controls: restoring the shape rule fails the first refusal case and nothing
else; restoring the accept-any-brace rule fails the second and nothing else.
The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7.
Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The
tightened rules put the file over the 300-line cap, and a `max-lines` disable is
forbidden, so the token reader moved to its own module: that side answers what a
script says, and says nothing about which differences between two of them are
allowed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(config): take main's docstrings for the two closure helpers
The order matched but the prose did not, so the trial merge still conflicted on the
whole block. Both docstrings are now main's own text, with one sentence trimmed: main
names `MobileBrowserPane` as the first component with a pin of its own, which is C6's
fact and not one this branch can assert.
What remains between this branch and main in this file is the `absWorkingDir`
parameter, which is what the engine-closure census plants a module with.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): write the page terminal's notify sink in an effect, not during render
React Doctor's one error on this branch, and a real one: `receiveRef.current = receive`
ran during render. React may replay or discard render work, so a mutation made there
can leak from UI that never commits — and this ref is read from a callback the mounted
document keeps, which outlives the render that installed it.
Moved into its own effect, declared above the mount effect so the first read already
sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0.
Found late because the first run of that gate was read through `| tail`, which reports
the pipeline's last command rather than the gate's own exit code.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): teach C7.1's order guard the three modules this lane added
The guard C7.1 landed says the document directory and the order list name the same
modules. On this branch three files are in that directory and not in that list, so it
was red on the merge — which is the guard working, and the fix is to name each of them
with its reason rather than to loosen the scan.
document-host-seams emitted, but ahead of the scope rather than inside the order
list, because the scope's defaults are its four functions and
the factory runs as the script is parsed
document-terminal-shape types only; esbuild emits nothing and an empty emission
would add a blank line to the document
page-document-modules the page's entry, not the WebView's, holding the same order
for a host that has no generator to splice them
Named one by one, not filtered by a pattern, so a fourth cannot join them by looking
similar. A third case asserts the seams module is neither in the order list nor the
scope module, which is the ordering the first two cannot see.
Red before this commit: C7.1's version of the file on this tree reports
`document-host-seams` and the other two as directory modules the list does not name.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): re-measure the session closure against the merged C7.1 base
Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved
from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this
lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`,
a module that enters this closure only once the page's component reaches it, so the
saving shows on the after side and cannot show on the base. Both readings are recorded
with the commit each was taken against, because a number with one base named and
another used is the kind of thing a reviewer cannot check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip comparator with the pin it was built for
The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts`
went in C7.1's own round-1 fold at 8da7680c9b, and `terminal-document-flip.test.ts`
went in this lane's first commit under ruling 18, because the flip pin holds only
while no module changes and C7.5 is the lane that changes them. What was left was a
tool, its token reader and a test of the tool, answering to nothing.
So `terminal-document-equivalence.test-support.ts`, the
`terminal-document-tokens.test-support.ts` C7.1 split out of it, and
`terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on
the comparator — bounding an absorbed body to one statement, and refusing a bare block
as `use();` against `{ use(); }` — since there is no comparator left to tighten. The
standing pin on the document is the whole-document byte golden, which is a stronger
claim than token equivalence ever was: it admits no normalisation at all.
`document-module-order.test.ts` gains the case its exception list was asserting in
prose. `document-terminal-shape` is not in the order list because esbuild erases a
module of type declarations to the empty string, and emitting it would put a blank
line in the document rather than a program; that emission is now measured and pinned
as `''`. If the module ever declares a value the case goes red and the module belongs
in the order list with its own line in the golden diff.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): make the document's error reporter the sixth host seam
Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which
inside the WebView is taking nothing from anyone — that document owns its page — and
on the page is a guest displacing whatever the host installed. Restoring it on dispose
was a patch over the takeover, not an answer to it: while a terminal was mounted, every
page error still went to the terminal's reporter.
So `scope.installErrorReporter` joins the five, with today's assignment as its default.
`host-notify` hands it the same handler it always installed, and the WebView's document
is the program it was.
The page supplies its own: an `error` listener that adapts the event to the reporter's
arguments, added on mount and removed on dispose, and `window.onerror` is never
written. This one seam is *called* as the modules are parsed rather than later, so the
mount now reaches `document-scope` on its own first and sets every field before a
single document module runs — which is also the safer order for the other five.
Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines
out, seven in, and nowhere else:
+ (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; }
- " createWebglAddon: createEngineWebglAddon"
+ " createWebglAddon: createEngineWebglAddon," and " installErrorReporter: installWindowErrorReporter"
- " window.onerror = function(msg, source, line, column, err) {"
+ " scope.installErrorReporter(function(msg, source, line, column, err) {"
- " };"
+ " });"
`terminal-webview-payload-hash.test.ts` takes the new length and digest.
Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror`
and a host that installs its reporter elsewhere leaving it null. The render check adds
a browser case: `window.onerror` is null before the mount, null after it, and null
after the component unmounts — with a real uncaught error thrown in between and
asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that
had simply stopped reporting, and a second error after dispose asserted to reach
nothing. Red with the mount's override removed: `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): empty the session closure's react-native-webview list
C7.6's census on main names the terminal as the last consumer and says whose work it
is: "The terminal is the third and is C7.5's, which drops the engine string and mounts
xterm in the document". This is that lane, so the list it left is now empty and the
session closure reaches `react-native-webview` from nothing at all.
Emptying a list weakens the case that reads it, because an empty result is also what a
scan that read no file reports, so two things change with it. The main case gains its
preconditions: the walk read a closure of more than 500 local modules, and it read the
three web siblings whose native halves are exactly the modules that would have
imported the package. And the control stops walking the list — with the list empty that
compared nothing against nothing — and walks the three native files instead, which do
import it, alongside the three web siblings, which do not.
`TerminalWebView.web.tsx` joins the answered list, so the case that the builder
resolves a web sibling rather than its native file now covers all three.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): pin the onerror seam against a handler the page actually owns
The case read `null` before the mount, while mounted and after dispose. That is true
but weak: a terminal that assigned `null` over a real handler would pass it, which is
exactly the takeover ruling 19 forbids.
So the page now installs a handler of its own in an init script, before the bundle
loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`,
compared inside the page because a function does not survive `evaluate` — at all three
points. Between them an uncaught error is thrown and both reporters are asserted to
see it: the page keeps the handler it installed, and the terminal's own listener still
works, so the readings cannot pass on a terminal that had simply stopped reporting.
After dispose a second error reaches the page's handler and not the terminal's, which
is what taking the listener off has to mean.
The `null` reading stays as its own case, because the other half matters too: on a page
that installed nothing the terminal must not leave a handler behind for the next
consumer to find.
Both go red with the mount's `installErrorReporter` override removed — `expected false
to be true` and `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): start the terminal document per mount (ruling 20)
Round 1's blocking finding: ES module bodies run once per page, so the page's
second mount re-imported nothing and inherited the first mount's elements,
listeners and error reporter. Measured after a remount: zero .xterm nodes in
the live DOM, no selection overlay, nothing reaching onEngineError, and
onWebReady still firing.
Ruling 20: no emitted module does work as it is parsed. Every top-level effect
moved into an exported per-module start function — 86 statements across 14
modules, plus three parse-time captures whose declarations became typed lets.
The generator emits one call sequence in module order at the foot of the
document, so the native script still runs them once at parse; the page runs the
same sequence per mount and dispose undoes the three that outlive the host
element (tap-dispatch, webgl-recovery, host-notify).
installErrorReporter now hands back its own undo, so it stays five seams at six
document sites rather than growing a sixth.
M2: a failed document chunk was an unhandled rejection with no engine error.
It now goes down the document's own reporting path, so the overlay names the
cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at
the wire in the render check.
L3: the seam count now reads five fields / six sites / three files everywhere.
L4: three unrelated web-overrides entries keep main's escaping.
Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256
2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the parse-time census tree without assertions
The changed-code gate refuses type assertions. The walker reached node fields
through `as Record<string, unknown>`; it now reads them with Object.entries,
which is checked and says the same thing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): move the document's state onto the scope (ruling 21)
Round 2's blocking finding, and ruling 20's second half: moving parse-time
effects out of the module bodies left the state behind. Nine module-level
bindings survived a mount, so the second terminal inherited a spent non-fatal
error budget (reporting nothing however it failed), the first terminal as its
committed surface (disposing it twice), and the first mount's momentum loop.
Every mutable binding now lives on the scope, and the scope carries one reset
the start sequence calls first: native once at parse, the page once per mount.
Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1,
host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1,
tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the
objects tap-dispatch and surface-touch-gestures used to own outright.
Because the reset is now the one initialiser, the start functions keep only
what it cannot do: element reads, listener installs and the reporter install.
Four start functions emptied and went; terminal-handle held nothing else and
is deleted from the order list. The scope type splits into state and host
seams, because a reset must restore the first and never the second.
Every stop function cancels what its module scheduled. Timers go back through
the handles the scope already held; frames go through the scope's own
scheduleDocumentFrame, so dispose can take back the ones no module tracks by
id. terminalGeneration and fitRetryToken carry forward across a reset, because
a stale callback tests itself against them and a reset to zero would make the
old number match again.
L2: the seams-before-scope case asserts the order in the emitted document, not
just non-membership. L3: the style docstring says what is true — one scope per
page, so mount refuses a second live document and gives the page back when a
mount fails.
Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256
6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse frames from a stopped document
The frame case went red under full-suite load: tearing the terminal down runs
the engine's own disposal, which calls back into these modules, and a frame
asked for on the way out was owed by nobody because the cancel had already run.
A stopped document now asks for no frames at all, so the ordering inside
dispose stops mattering.
The render case is also rewritten around the work that survives a loaded
machine. It gives the terminal a scrollback and sends one wheel, which reveals
the scroll indicator and arms the 550 ms timer to hide it again, and the
boundary between the two mounts is drawn when the first terminal leaves the
page rather than when the component is told to go — React unmounts on its own
schedule, and a callback that runs while the first terminal is still up is not
a leak. The precondition counts what the document scheduled under the first
mount, so an empty leak list cannot mean the wheel reached nothing.
Verified both ways at this head: red with stopViewportTransform and
cancelDocumentFrames removed, green with them, and green in the whole
config/scripts suite.
Payload 726081 -> 726195, sha256
67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): style only what the page mount owns
CodeRabbit on document-style.ts:16. The mount appended the document's whole
stylesheet to the page head, so its `*`, `html` and `body` rules restyled every
screen the shell can show and went on doing it after unmount. Ruling 19's
shape: the native document owns its page and keeps the sheet as it is; the page
mount may style only what it owns.
The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and
TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted
document does not move for the split - verified byte-identical before the seam
below. The page injects the element half only, with every selector held under
the host's own class, and xterm's sheet goes through the same rewrite. The
rewrite refuses an at-rule rather than passing its inner selectors through
unscoped.
A second leak of the same kind was in the same measurement: applyTerminalTheme
wrote the terminal background straight onto `html` and `body`. That is a sixth
seam - six fields at seven document sites now. Its default does exactly the two
writes it did; the page paints the host element instead. Emitted lines, old to
new: `paintWindowDocumentBackground` added beside the other defaults (3 lines);
`paintDocumentBackground: paintWindowDocumentBackground` added to the seam
factory (1 line); in applyTerminalTheme, the two `document...style.background`
writes become one `scope.paintDocumentBackground(background)`.
Leaving the sheet in the head after unmount is kept, and is now defensible: the
host drops the class on dispose, so every rule in it matches nothing until the
next mount.
The render check gains a case comparing `body` and `html` computed styles,
while mounted and after dispose, against a page of the same application with no
terminal on it, and asserting no rule of the injected sheet matches an element
outside the host. Verified red both ways at this head: unscoped sheet moves
`background-color` and `box-sizing`, and the inline theme write moves
`background-color`.
Payload 726195 -> 726363, sha256
9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the page mount's rules and frames their own oracles
Round 3 blocks on evidence, not on shipped behaviour. Each item:
H1. The scoping had no positive oracle: dropping the host class, or injecting
an empty xterm sheet, left the render check green, because every assertion was
about rules not escaping. The containment case now also reads four things off
the live elements under the host — xterm's own `position: relative`, the
viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width,
and the overlay's `position: fixed`. Red both ways: no host class reds all
four, an empty engine sheet reds the first.
H3. `cancelDocumentFrames` had no witness: the only leak the timer case could
see was the 550 ms hide timer, which its own module's stop cancels. There is
now a case whose witness is a frame taken through `scheduleDocumentFrame` —
the fit retry loop, with the surface hidden so the fit never commits and one
frame is always owed at dispose — and it reds when only `cancelDocumentFrames`
is removed. A unit covers the registry itself: a frame is held until it runs,
a cancel takes back every pending one and then refuses to schedule, and a reset
re-enables it.
The two scheduling cases now assert on their own witness kind, so neither can
stand in for the other, and the recorder judges a leak by whether the
`#terminal-container` that was on the page at schedule time is still in the
document — React unmounts on its own schedule, and a callback that runs while
the first terminal is still up is not a leak. The timer witness moved from the
scroll-indicator timer to the long-press timer, because the first needed a
drained scrollback and raced the engine under load; its precondition caught
that rather than passing.
L1. The two seam docstrings each sit on their own function.
L2. The parse-time census plants an element-read initialiser, which the
statement filter cannot see, and an inert object literal, which a reader that
flagged every initialiser would wrongly report.
L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that
never committed leaves two terminals and only one was reached. Deduplicated,
because they are the same object whenever no swap is open, and pinned both ways.
L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT.
Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render
check: 12 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the page document's dispose idempotent and owner-checked
CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A
handle outlives what it built - the component keeps one in a ref and React can
run a cleanup after a later mount has started - and everything dispose touches
is shared: the scope, the module sequences, window.__engineErrors. So a second
call, or a call from a handle whose document had already been replaced, tore
down the terminal that was on the screen and handed the page away while it was
still in use.
Each mount now carries a token, and dispose acts only when that token is still
the live one. A token rather than the host element or its class: two mounts can
be handed the same element, because the page remounts into a host React has
reused, so an element is not an identity and the class says only that some
document is using the host. The failed-mount path releases the page under the
same check.
Pinned both ways, red with the check removed: disposing twice leaves a terminal
put back after the first teardown alone, and a stale handle disposed after a
second document mounted changes nothing - the live markup stays, its terminal
is not disposed, and the page is still refused to a third mount.
Golden unchanged at 108,329 bytes; payload and hash unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let a pending page mount be disposed before its import lands
Round 4 on #21809.
H1. The mount claimed the page before its dynamic import and handed back a
promise, so a component cleanup that ran while the chunk was still in flight had
nothing to dispose: the claim outlived the mount it was made for, and Reload —
the recovery ruling 20 names — was refused as a second document. The claim, the
markup and the handle are now made synchronously, `ready` settles on its own,
and a mount disposed while its import was in flight releases without starting
anything. Pinned in the render check by holding the document chunk 20 s past the
15 s readiness watchdog, clicking Reload and waiting for the second mount to
become live; red at that wait before the change.
M1. The frame case's precondition asserted that a frame had been asked for while
the document owned the page, not that one was owed when it was disposed. The fit
retry commits on its first attempt whenever the grid still measures, so a dispose
between two refits owed nothing and agreed with an empty leak list for exactly
the reason under test — one run in five. The refit and the unmount now share one
discrete click, which React flushes before the event returns, and a mutation
observer reads the registry at the instant the host is emptied. Five red runs
without `cancelDocumentFrames`, all on the leak and none on the precondition,
and five green with it.
M2. Two mounts handed the same element, which is what the token is for: the
other six cases use a different element each, so a host comparison passes all of
them.
L1. A throw inside the start sequence released the token but ran no stop, leaving
the host-notify error listener installed until the next reset nulled its undo.
The sequence now unwinds the starts that completed, in reverse, before it
rethrows.
L2. A render case comparing the window and document listeners the page holds
with no terminal on it, before and after a mount, so a stop that forgets one is
a failure rather than a second copy per terminal ever shown.
L4. Separated the stacked docstrings in the parse-time-effects census.
The render check's bundle, server, browser and page helpers move to their own
fixture module: the cases are what is under review and the scratch route tree is
not, and the file was 16 code lines under its cap.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): count the page document's leaked frames from dispose, not from detach
CI's addendum to round 4's M1: the frame case failed with the fix present,
`expected [ Array(1) ] to deeply equal []`, on a slower runner.
What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every
other frame the document asks for — the document has no other rAF call site. It
is not an escape from the registry, so the registry is not what changes here.
Why it was counted: React unmounts in two steps. The mutation phase detaches the
host, and the passive cleanup that calls `dispose` runs after it — about 1 ms
later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner
shape this failed on. A frame served in that gap runs with a detached container
while the document is still live and has not been asked to stop, and nothing
could have taken it back: `cancelDocumentFrames` had not been called yet. The
oracle judged by the captured container's connectedness, so it read the gap as a
leak. It now counts only what runs after the last statement of `dispose`, which
is the class coming off the host, observed on the element because React may have
detached it already.
The same reading fixes the other direction. The precondition is read at that
same moment, and the witness is a refit re-armed from a frame of the test's own,
so the document is owed a frame at the end of every frame the browser serves and
a dispose cannot land where nothing is owed. The single refit the case used
before bought one frame, and the retry loop commits on its first attempt
whenever the grid still measures.
Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two
runs of three unthrottled, and in five of five with the CPU throttled 20x, where
the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with
`cancelDocumentFrames` removed, five red runs, all on the leak read and none on
the precondition.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop a page mount that lost its claim before it writes the scope
Round 5 on #21809.
F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await
import(...)` the whole body ran whatever had happened in the meantime: it
overwrote the six seams, called `startPageDocumentModules` and added the resize
listener, and only then did the caller's `.then` read the claim and throw the
result away. Everything after that await is shared — the seams are fields on a
module-singleton scope, and the start sequence resets that scope and installs
the document's listeners — so a mount disposed while its chunk was in flight was
writing over a mount that owns the page. The claim is now re-read the instant
the import lands, before any of it, and the build returns null.
`ready` for such a mount resolves rather than rejecting. Nothing failed: the
caller asked for the terminal and then asked for it to go away, and the chunk
arriving afterwards is not something for the error overlay to name. Before this
it rejected with a TypeError from `startSelectionMenuButtons` reaching for an
emptied host.
F2. The rejection handler called `release()` unconditionally, emptying a host the
mount may no longer own. It now releases only when the page is still its own.
Pins, both red first. In happy-dom: mount, dispose, then await ready — no
listener, timer or frame added while it resolves, the six seams unchanged,
`terminalGeneration` unmoved because the start sequence never ran, and the page
free for the next mount. Without the fix that case rejects with the
`startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight
case now reads the page's listeners with no terminal on it and compares them
against a page that mounted once and disposed once; without the fix the
abandoned mount leaves `window error` and `window resize` behind, because the
second mount's scope reset nulls the first mount's reporter undo.
The listener snapshot helper is shared with the mount-and-dispose case rather
than written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): give the render fixture's server and scratch tree back when it cannot start
CodeRabbit on the render fixture, plus its note on `release`.
The fixture. `chromium.launch` is the last step of the setup and the one that
fails in practice — no Chromium on the machine, an
`ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle
server is listening and the scratch tree is on disk. Rejecting there left the
caller without a handle, so `afterAll` had nothing to close and both stayed
allocated; the listening socket is the one that bites, because an open server
handle keeps the vitest worker alive after its last test has reported. The setup
after `mkdtemp` is now wrapped, gives back whatever it managed to take, and
rethrows the original error rather than anything the cleanup raised. The normal
close path awaits the server-close callback instead of firing it.
`release` in the page mount. The ownership check covered the claim but not the
two lines that make the terminal disappear, so a release that skipped the claim
would still empty the host and drop its class. The check now guards the whole
function, and round 5's caller-side check is gone as a duplicate of it: one rule,
inside the thing it governs. Both existing callers are unchanged in behaviour —
the synchronous planting catch always owns the page, and the rejection handler
was already guarded.
Pinned red first. The new case points the launch at an executable that is not
there, then asks the port the fixture actually served on for a connection and
reads the scratch directories in the temp dir. Without the rollback the port
still accepts and the scratch tree is still there; with it, neither. The port is
recorded by wrapping the real `createBundleServer` rather than standing a double
in front of it, and the case asserts a server was created at all, or the refusal
would mean nothing.
Two oracles were discarded on the way. `rejects.toThrow()` with no argument
passes for a build that broke for its own reason, so the rejection is matched by
message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not
`TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both
arms and agreed with everything; it also still lists the handle at the moment
the close callback runs.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): read the render fixture's rollback in a temp root of its own
Two defects in the case I committed in cb1833e675, both found by running it.
The anti-slop gate refuses module mocking, and it is right to: the case recorded
the served port by mocking the harness module around the real
`createBundleServer`. Gone, with no disable.
Its replacement read the shared temp directory for the fixture's scratch prefix,
which the render check next door writes to from a worker of its own. So the case
watched that tree appear and be swept up mid-run and called it a change: one red
in four alone, and red in the full suite, where the two run together. `TMPDIR`
now points at a directory this worker made, so the fixture's scratch tree lands
somewhere nothing else writes and what is left in there afterwards was left by
the setup under test. The failed launch also leaves Playwright artifacts and a
browser profile in there, which are Playwright's to clean, so the reading is
filtered to the name the fixture gives its own trees.
The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as
Node spells it, and read a tick after the close callback, because the handle is
still listed while that callback runs.
Both halves now fail on their own without the thing they measure: with no
rollback at all the socket count is one above its baseline, twice out of twice;
with the rollback but no `rm`, the scratch tree is still there. Three green runs
with both.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hand the started document to the mount in the turn that started it
Round 6's two LOW items, and the pins for the owner-checked release.
LOW 1. `started` was assigned in the `.then` after the build, a microtask later
than the start sequence and the resize listener it installs. A dispose in that
window found nothing started, skipped the teardown and released the page with the
document still running on it. The build now takes an `adopt` callback and calls it
as its last statement, inside the guarded region, so whoever has to undo the
start is holding it before that turn ends. Pinned by queuing the dispose behind
the document import the build awaits, which lands in exactly that window: without
the change the started document's resize listener survives the dispose, five red
runs out of five.
The owner-checked release, which landed in 8b37221b57 without a pin of its own.
The one path that reaches a mount's cleanup holding someone else's page is a
rejected import: everywhere else the build re-reads the claim after its await and
stops, but a rejection never gets that far. So the pin drives that — the chunk
fails for the first mount only, the mount is disposed while pending, a second one
is built into the same element as Reload does, and then the first rejection
arrives. Without the guard inside `release` it empties the live mount's host:
three red runs out of three, on the markup. It also disposes the abandoned handle
a second time afterwards and asserts nothing moves, which is LOW 2's missing pin
for round 5's F2.
That case is its own file because the import has to fail before the mount module
loads, and the mocking the failure needs is only permitted in `.test.ts` — the
anti-slop override does not cover `.test.mjs`, which is what refused the port
recording in the render fixture's case. It fails once, so the mount that replaces
it gets real modules and is a live document worth protecting; its own resize
listener is the witness that it started.
Two oracles were dropped. Vitest reports its own message when a mock factory
throws, not the one thrown, so which import failed is read from the factory's
counter instead. And a counter of successful factory calls read zero even though
the second mount got a working document, which measures vitest's caching rather
than this code; the live mount's listener replaced it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the listener wrappers the mount pins install
The mobile tests-typecheck ratchet was red on 63eb8a40ae: six TS7006 implicit
`any` parameters in each of the two mount pins, from arrow functions assigned
over `window.addEventListener` and `window.removeEventListener`. An overloaded
method gives an assigned arrow no contextual parameter types, so each wrapper's
`type`, `listener` and `options` were implicitly `any` under
`tsconfig.test.json`, which the product typecheck does not read.
Both wrappers now take their parameters from the bound original as
`Parameters<typeof realAdd>` and spread them through, so the signature is the
real one rather than three widened parameters. No casts and no `any`.
Re-verified that the change did not quietly disarm either pin, because a recorder
that counted nothing would also go green: with `release` unguarded the rejection
case still fails on the live mount's markup, and with the adopt deferred by a
microtask the single-mount case still fails on the started document's resize
listener surviving its dispose.
The ratchet itself is the finding worth keeping. It is not part of the mobile
`tsc` the rest of my gate set runs, and it had dropped out of that set when these
folds began, so three reports listed the other ratchets and not this one. It is
back in, and stays in.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart
Round 7's five items.
1. The queued-dispose case's precondition was vacuous. It read the host for a
missing container, which dispose empties on every path, so a build that returned
straight after its ownership check satisfied it. The wrapper now counts resize
adds and the case asserts exactly one, which is the document having started. Red
under that mutation, on the count.
2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that
also refused replaced the error the caller needs — the reason the setup failed.
The rollback is best-effort now and the original error is what comes back.
3. That cleanup stopped at the first throw, so a browser refusing to close took
the socket and the scratch tree with it, which is the leak the rollback exists to
prevent. Each of the three is asked independently and the first failure is
rethrown after all three have been tried.
4. The rejection case restores its `window` patch in a `finally`, as its sibling
does, so a failure part way through no longer leaves the patched functions behind
for everything that runs after it.
5. `dispose` left `started` set. `send` reads it, and what it holds names the
page's one set of document modules, so a stale handle could route a host command
into whichever document is live next. Nulled, and pinned: the stale handle pings,
and with the old code the *live* mount's `receive` answers `pong`, because the
scope's seam belongs to it by then. The precondition is the live handle's own ping
being answered, so the silence is the stale handle declining rather than the
command doing nothing.
Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup
itself, reachable only by making a browser or a socket refuse to close, and
standing something in front of Playwright to do it is what the anti-slop gate
refuses in this file's suffix. The rollback's own pin still covers the path that
matters, and both changes are read by it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): emit the terminal document as a factory
Ruling 22, commit 1 of C7.5b. The generator's concatenation already gave the 38
modules one function scope with one local `scope`; naming that scope a function is
what makes it the shape both hosts run, and what will let the page have its own
state per mount instead of a module singleton with a reset between them.
`createTerminalDocument(host)` is emitted around the same module bodies, in the
same order, followed by the same start sequence. It then declares `stop`, which
calls every module's stop in reverse order and takes back the frames the document
is still owed, and returns `{ send: handleMsg, stop }`. The native document is
that function plus one call with no argument, which is what the WebView has always
run: no argument means every seam is the window read it already did.
`createTerminalDocumentScope` takes the host and spreads the hooks it names over
the window defaults, filtering undefined so absent and present-but-undefined mean
the same thing. The emitted scope declaration is the one line the host reaches, so
the generator rewrites it and refuses if the line it expects is not there — a
rename would otherwise leave every call on the defaults with nothing to say so.
The golden moves by the wrapper and that one line, and by nothing else. 108,329 to
108,831 bytes, the whole diff:
-(function() {
+function createTerminalDocument(host) {
- function createTerminalDocumentScope() {
- return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() };
+ function createTerminalDocumentScope(host = {}) {
+ const named = Object.fromEntries(Object.entries(host).filter(([, hook]) => hook !== void 0));
+ return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams(), ...named };
- const scope = createTerminalDocumentScope();
+ const scope = createTerminalDocumentScope(host);
-})();
+ function stop() {
+ stopSurfaceTouchGestures();
+ stopTapDispatch();
+ stopSelectionOverlay();
+ stopNormalBufferSmoothScroll();
+ stopHostNotify();
+ stopTerminalInit();
+ stopWebglRecovery();
+ stopFitScale();
+ stopViewportTransform();
+ cancelDocumentFrames();
+ }
+ return { send: handleMsg, stop: stop };
+}
+createTerminalDocument();
The byte golden and the payload hash are re-pinned once: 726,363 to 726,865 bytes,
sha256 9950f177 to c7bbcb0b.
Four test files sliced the document with their own copy of the IIFE bounds, which
ruling 17 allows moving. They now share one reader in the test-support module
beside the one that locates a single module, and that reader names the factory and
its call. Every assertion is unchanged. The module-order guard and the region
reader compare against the text the document carries rather than a raw emit, since
the scope module is the one the generator rewrites; both go through one exported
function so neither can describe the rewrite differently from the generator.
`TerminalDocumentHostSeams` and the new `TerminalDocumentHost` moved to
`document-host-seams.ts`, which owns the six functions they type. Types emit
nothing, so the golden is unchanged by the move; it keeps `document-scope.ts`
inside its 300-line cap with no disable and no bump.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): emit the page's terminal document factory beside the WebView's
Ruling 23, and the first half of C7.5b commit 2: the artifact the page will
import. The page cannot run the native script, because building a function from a
string needs `eval` and the page's policy refuses it, and it cannot run the
modules either, because they are one singleton while the whole point of the
factory is a scope per call. So one emitted body gets two wrappers.
`buildTerminalDocumentFactoryBody` is now the shared half: the modules in order,
the start sequence, the stop handle and the return. The native script wraps it in
the declaration and the trailing call, exactly as before. The new
`terminal-webview-document-factory.generated.ts` wraps the same lines in a
`@ts-nocheck` module whose only other content is the type import and the
annotated signature. One generator run writes both, so the page's factory cannot
be a build behind the WebView's.
`@ts-nocheck` covers this one generated file. Every line of its body is esbuild
output from a module that was type-checked at its source, with `declare global`
blocks and type re-exports already erased and constants already substituted; the
one line a caller reads is the signature, and the generator writes it with its
types. `TerminalDocument` joins `TerminalDocumentHost` in `document-host-seams.ts`
as the shape the factory returns.
The pin is byte equality. `document-factory-artifacts.test.ts` strips each
wrapper and holds the remaining text equal, so the byte golden pins the page's
artifact by construction rather than by a second golden; it also reads the file on
disk against what the generator would write now, since that file is gitignored and
built by postinstall, and it refuses a trailing call in the page's copy, which
would start a document as the module was imported.
The path joins `.gitignore` and the oxlint ignore list beside the engine artifact.
The consumer census gains the generated file by name: it is the document, and its
one import is the host contract its signature is written against.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): call the document instead of starting its modules
The page's half of ruling 23, and ruling 24. The mount plants the markup and calls
the factory; the handle is `send` and a `dispose` that stops it. The document is a
function, so the page holds an object per call and nothing else.
Deleted with the singleton it was written for: `page-document-modules.ts`, the
claim token and `liveDocument`, `release`, the owner-checked `dispose`, the
second-mount refusal, `resetTerminalDocumentScope`, the `adopt` callback, `ready`
and every pending-import path. All of it existed because two mounts shared one
module-level scope and because the handle had to come back before its import did.
A call is a document now, so a second mount cannot reach the first one's state and
a caller's cleanup cannot arrive before there is something to clean up. The
second-mount refusal is not replaced by a one-line guard: with a scope per call
there is no shared state left to refuse for, and a host element with two
documents planted in it is the caller's own doing, visible on the screen.
Ruling 24 splits `message-bridge` by what it is, which is what made the page able
to run this text at all. Two more seams, eight now: `installHostTransport`, whose
window default installs the `message` listeners on window and document and hands
back their removal, and `hasEngine`, whose default is the `window.Terminal` the
engine bundle installs. The page answers a transport that installs nothing,
because its transport is the handle, and an engine that is always there, because
the engine is the import above. So the page no longer takes the shell's frames or
reports a missing engine on every mount, and `stopMessageBridge` takes the
listeners off — the WebView never removed them, which ruling 21 asks for.
The refit the bridge happened to own moves to `fit-scale`, which is whose work it
is; both hosts start it, and the mount's hand-copied five calls are gone. The
engine's disposal moves into `stopTerminalInit` for the same reason: the mount
cannot reach the scope any more, and a stopped document's terminal is a WebGL
context nothing will read again.
The start sequence the generator emits is now inside the document's own undo: a
start that throws runs `stop` and rethrows, so neither host can be left holding a
listener from a build that failed. That replaces the deleted entry module's
unwind, and it covers every start rather than the four that had one.
Readiness arrives the same way on both hosts. The document posts `web-ready`
through `postToHost`, which the controller already handles, so the mount-side
`confirmWebReady` is gone. That flush is also the one caller that reaches `post`
before the effect has a handle, which is why the component's queue stays and now
says so.
The golden and the payload hash move, 102 diff lines: the two seam defaults and
their state fields, the reset gone, `startFitScale`, the disposal, the bridge over
its seams, and the start sequence inside its try.
Tests: the seam tests and the unwind test move to the factory and the derived
start sequence; the frame registry builds a fresh scope instead of resetting one;
the two mount test files and the page entry's order test go with their subjects.
The render check keeps every behavioural case and loses two whose subject the
static import removed — a Reload while the chunk is in flight, and a chunk that
will not load, which is now the route's chunk rather than the document's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop censusing state a call of the document already isolates
Ruling 22 answers what ruling 21's state half was for. A module's top level is
emitted inside the factory, so a `let` there is one binding per call — which is
exactly what moving it onto the scope was achieving. The census that refused it,
and the planted-module precondition beside it, go.
The effect half stays, and the distinction is what a stop can reach. An effect in a
module body runs at the position its module is emitted rather than in the start
sequence, so no stop function undoes it and each call leaks another one. A binding
leaks nothing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop the derived start sequence warning on every suite run
The helper reaches its neighbours through a variable specifier, which the bundler
answers by rewriting as a glob — and it refuses to glob the directory the import is
written in, so every suite that loads this file printed the refusal twice.
`@vite-ignore` leaves the specifier alone and the module runner resolves it, which
is what was already happening. An extension does not help: with one the refusal
becomes the own-directory rule, and the path alias is not resolved for a runtime
specifier at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): separate the two commits inside the closure reading
The factory arriving is not the whole -1,890. Making the document a factory put the
`host` argument on `createTerminalDocumentScope`, which is this lane's only edit to
a module the closure already carried, and that alone is +80. Both numbers are in
the note now, so neither commit's cost is read as the other's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): read a document's elements from the host it was planted in
The last thing two documents on one page shared. Ruling 22 gave each call its own
scope, but the element reads were `document.getElementById` and the ids are in the
markup every host plants, so the second document's start sequence took the first
host's surface, overlay, handles and menu — two documents driving one terminal,
with the second host left empty.
Reachable, not theoretical: expo-router keeps the outgoing screen mounted for the
length of a stack transition, so two routes that both hold a terminal have two live
documents on the page while the animation runs.
`root` joins the host argument and `elementInRoot` is the one reader; the ten reads
in runtime-constants, surface-swap, selection-state-and-eviction and text-scaling go
through it. No id is renamed and nothing is refused: two documents on one page are
two terminals.
Two deviations from the ruling, both about *when* the default is read. `root` is
`ParentNode | null` with null meaning "the page I am in", rather than defaulting to
`document`: a data default is evaluated whenever a scope is built, which put a DOM
read into every slice evaluation and took eight keyboard-avoidance cases down with a
`ReferenceError` in their `vm` context. Null defers it to the read, which is the rule
the eight seams above it already follow. And the reader lives in
`document-host-seams.ts`, which declares the type, taking the root as an argument:
in `document-scope.ts` it was four lines over the file's 300 (no bump, no disable).
Red first, and the red was the second document: with a page-wide read the second
engine opens on an element outside its own host. `document-host-root.test.ts` plants
two hosts, starts a document in each, and reads which surface each engine was opened
on through the `createTerminal` seam, because the scope is not reachable from
outside. Falsified again after the fix by pointing the emitted reader back at
`document`: red, one case.
Two neighbours checked while here. The document-level touch listeners are already
host-scoped, because every handler tests its target against the scope's own surface,
overlay and handles, which are now this document's. `window.__engineErrors` is the
one page global left, and it is now kept rather than replaced per mount: a capped
diagnostic buffer, where a second mount was costing the first its captured lines.
Golden 27 diff lines, hash and length repinned: the reader, the `root: null`
default, and the ten reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the two-host engine double as the shape the seam returns
The double was reaching `createTerminal`'s return type through
`as unknown as Parameters<typeof queueMicrotask>[0] & never`, which the type-aware
gate reads correctly as an intersection with `never` and which was a cast standing in
for naming the type.
`TerminalDocumentTerminal` names it. Every member the type declares is present — the
ones `init` and the start sequence reach do something, the rest answer in the shape
their caller reads — and the shape needed no narrowing to accept a double. Two things
the type does not declare moved off it: where `open` was called is handed back beside
the terminal rather than exposed as a second getter, so the literal carries nothing
excess, and the buffer gained the `getLine` the type requires.
No cast, so nothing to write a SAFETY line about. Still red without the fix, checked
again after the retype by pointing the emitted reader back at `document`: one case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): filter a document's page-wide touch listeners to its own host (OTA phase C, C7.5b round 1)
Round 1 H1. The dispatcher's four listeners are on `document`, so with two
documents on one page (legitimate since `712daa80e6`) each is handed the other's
touches, and the two-finger branch acts before any target filtering: a pinch in
host B posted `mobile-clip-cancel-by-pinch` from document A and dropped A's
selection. Fixed at the source, one predicate beside `elementInRoot`, asked once
at the top of each handler rather than inside a branch. `root === null` is the
WebView, where the document is the page, so it answers yes to everything and the
native document is unchanged.
`e.target` is the element the finger went down on for the life of the touch, so a
select-drag travelling outside the host still answers yes on move, end and cancel.
Census of every global listener install under `src/terminal/document/` (non-test):
- `tap-dispatch.ts:241-244`, four capture-phase `document` touch listeners
(touchstart, touchmove, touchend, touchcancel): MUST be root-filtered; this fix.
- `document-host-seams.ts:165-166`, `window`+`document` `message` in
`installWindowHostTransport`: WebView-only. It is that host's transport seam
default and the page installs nothing (ruling 24), so no page carries two.
- `fit-scale.ts:163`, `window` `resize`: page-wide by nature. A viewport change
concerns every document on the page and the event has no target in either host;
both must refit.
- `webgl-recovery.ts:104`, `document` `visibilitychange`: page-wide by nature.
Backgrounding concerns every document on the page; its target is the document.
- No document-level mouse, wheel, keyboard or selection listener exists: those
are all on `targetSurface` or the menu buttons, read through `elementInRoot`,
so they are already inside their own host.
Red-first, the reviewer's own repro in `document-host-root.test.ts`: A and B both
in select mode, a two-finger touchstart in B's surface. Before: 2 failed
(A posted the pinch cancel too, and the control in A's own host cancelled B).
After: 3 passed. The control keeps the assertion honest — the same touch inside
the document's own host still cancels its selection.
Golden and payload hash move (regen is a review event): six hunks, +22/-1.
`eventTargetInRoot` emitted after `elementInRoot`; `touchIsThisDocuments` after
the CAPTURE constants; the three-line guard at the top of each of the four
handlers; `onDocumentTouchCancel()` becomes `onDocumentTouchCancel(e)`.
Document 728,119 -> 728,589 bytes, sha256 5b65315b... -> 1556f532...
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): unit-test the page mount's three paths no happy path reaches (OTA phase C, C7.5b round 1)
Round 1 M2. `terminal-web-document-mount.ts` had no unit test: the only reading
of it was the render check, which drives the whole page bundle in a browser —
right for behaviour, too coarse for three lines that only a failure reaches.
The body's "five test files whose subjects no longer exist" is wrong for three of
them. What ruling 22 deleted was the machinery (the claim token, `liveDocument`,
the owner-checked dispose, the second-mount refusal); these three subjects
survived it and lost their only cover:
- both engines disposed when a swap never committed (`terminal-init.ts:203-213`);
- the host given back when a start throws (`startDocumentOrGiveTheHostBack`);
- the component naming that throw's cause (`TerminalWebView.web.tsx:83`).
`terminal-web-document-mount.test.ts` (happy-dom) covers all three against the
real generated factory. Only the factory's *arrival* is mocked, delegating to the
real `createTerminalDocument` except for the one case that makes a start throw, so
no stub stands in for the program under test.
Six cases, each red against a deliberately broken line:
- two distinct terminals both disposed. Broken `new Set([scope.term,
scope.committedTerm])` -> `new Set([scope.term])`: expected [1,1], got [0,1].
- the same terminal disposed once. Broken the set -> a plain array: expected 1,
got 2. The pair is the dedup's own oracle; either half alone passes for the
wrong reason.
- the host emptied and the class dropped on a throw. Broken by deleting the two
lines in the mount's catch: host still carried `#terminal-container`.
- control: a live document keeps the markup and the class, so the two assertions
above cannot pass for a mount that planted nothing.
- `onEngineError` gets `terminal document failed to start - engine missing`.
Broken by deleting the component's `receive` in its catch: expected one
message, got none.
- control: nothing reported when the document starts.
The engine double moves to `document-terminal-double.test-support.ts` and both
readers of the seam share it; a second hand-written copy of thirty members would
drift as the shape grows. It now counts disposals beside reporting `open`.
Terminal suite 67 files / 625 tests -> 68 / 631. No product line changed, so the
golden and the payload hash do not move.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin both generated wrappers, and say what a shadow root would break now (OTA phase C, C7.5b round 1)
Round 1 M1 and L2.
M1, the mount's stylesheet comment was one version behind: it said the document
reads its elements with `document.getElementById`, which `712daa80e6` replaced
with `elementInRoot`, and drew its shadow-root conclusion from that read. Both
halves re-derived rather than reworded. A shadow root no longer breaks the reads
(`elementInRoot` is a `querySelector` under the host, which a shadow root
answers); it breaks this sheet, because a rule in the document's head does not
cross a shadow boundary, so it would have to move inside each root and be parsed
once per host instead of once per page.
L2, `document-factory-artifacts.test.ts` anchored the page body at `):
TerminalDocument {` and nothing else, so the header, the `@ts-nocheck` line, the
`import type` and the parameter's own line could all drift with the test green —
and that signature is the one line a caller of the page's artifact reads. Both
wrappers are now literal lines: nine for the page (header, directive, import,
blank, the three-line signature) and one plus two for the native script
(declaration, closing brace, trailing call). Literal rather than the generator's
own constants, which would only agree with whatever it emits.
Red controls, each with the generator changed and then restored:
- the page's `import type` reordered to `{ TerminalDocumentHost, TerminalDocument }`:
2 failed ("the page module opens with its wrapper", and the on-disk reading).
- the native trailing call changed to `createTerminalDocument({});`: 1 failed
("the native script closes with its wrapper"). The old anchor caught neither.
No emitted line moved: golden and payload hash unchanged, terminal suite 68 files
/ 631 tests.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): thread the document scope and bundle the page's script
Ruling 25. The terminal document stops being a string the build machinery assembles
and becomes ordinary TypeScript: every module function that reads document state takes
`scope: TerminalDocumentScope` as its first parameter, `document-scope.ts` exports the
types and `createTerminalDocumentScope(host)` and nothing else, and `tsc` is the oracle
that the threading is whole (a missed parameter is a type error).
`create-terminal-document.ts` is hand-written, not emitted: it builds the scope, runs the
eleven starts inside a try that unwinds on a throw, stops the ten in reverse with
`cancelDocumentFrames` last, and returns `{ send, stop }`. `native-document-entry.ts` is
one statement. The concatenator becomes an esbuild IIFE bundle of that entry at the
Chrome 74 floor, written as a string the same way the engine artifact is.
The byte pin cannot survive that and does not try to: esbuild merges module scopes and
renames the threaded parameter (`scope` -> `scope2` where two modules collide), so the
document's text is no longer a stable artifact and the behavioural suites are the proof.
`native-document-bundle.test.ts` evaluates the real bundle and reads three behaviours the
phone depends on: it announces `web-ready`, an init message opens the engine inside
`#terminal-surface`, a ping is answered, and a missing engine global reports fatally
instead of starting.
Rewritten tests, old oracle -> new oracle:
- host-seams, write-queue, document-frame-registry, document-parse-time-effects:
evaluated a region of the generated text -> import the module and pass a scope the case
builds.
- terminal-webview-engine (WebGL recovery), terminal-webview-theme,
terminal-webview-text-zoom, terminal-webview-query-reply,
terminal-keyboard-avoidance-webview: a `vm` evaluation with injected globals -> the
imported functions over a scope whose seams are the case's own doubles.
- terminal-webview-url-tap: a function extracted out of the document's text and evaluated
-> the document's `osc-link-tap` exports imported directly.
- terminal-webview-reflow, terminal-webview-scroll-routing, terminal-path-tap,
terminal-webview-tap-routing, terminal-webview-wheel-scroll: assertions over the
assembled document text -> the same assertions over the module's own source, read
through `document-module-source.test-support.ts` (TypeScript, so no semicolons).
- document-host-root, document-start-unwind: imported the generated factory -> import
`create-terminal-document`.
- terminal-webview-consumer-census: the generated factory was a census exception -> it no
longer exists.
- config/scripts closure tests: re-measured for the bundled document.
Deleted with the machinery they served: the concatenator's emit/substitute/order code,
`terminal-document-module-order.mjs`, the document fixture builder,
`generated-document-region.test-support.ts`, the byte golden and its identity test, the
payload-hash pin, `document-factory-emit`, `document-factory-artifacts`,
`document-module-order`, and the generated factory artifact with its gitignore and
lint-ignore lines.
Two assertions the tests no longer need: the published contrast floor arrives unvalidated
from a host of unknown version, so `TerminalDocumentThemeMessage` types it the way the
router types its other wire fields and `normalizeTerminalContrastOverride` remains what
decides it is a number; the recovery harness holds its timer callback in a wrapper rather
than asserting a narrowing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): dissolve the document's constants module into its owners
`document-constants.ts` existed because the document was a string: a string cannot
import, so the generator substituted JSON literals into the text and the web page imported
the same bindings to keep one source. The document imports now, so the indirection is a
re-export shim over four real owners and each site reaches the owner instead.
- `terminal-theme.ts` takes the background fallback from the theme's own `colors`.
- `url-tap.ts` imports the two patterns and the length cap from
`terminal-webview-url-tap.ts`, which is where the page's copy reads them, and the three
local aliases go with the substitution they were shaped for.
- `text-scaling.ts` and `document-scope.ts` take the presets from `storage/preferences`.
- `terminal-init.ts` and `document-scope.ts` take the caret options and the built-in theme
from `terminal-webview-html/theme.ts`.
One test oracle moves with it: the URL tap's "both copies spell the pattern identically"
case pinned the substituted assignment line, and now reads the document module's import of
the page's own constant. The resolver cases that compare the two behaviours are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): count only the fingers inside this document's own host (OTA phase C, C7.5b round 2)
Round 2's residual of `9824145e1f`'s class, one level in. `eventTargetInRoot`
settles whose event it is; every branch then counts `e.touches`, which is every
finger on the screen. A finger resting in host A is therefore B's second finger:
a one-finger touch in B's own surface reads `length === 2`, latches a pinch and
drops B's selection, and on touchend `length === 0` is never true so B's surface
tap never fires.
`touchesInRoot(root, touches)` beside `eventTargetInRoot` returns this document's
own fingers, and every count and index reads through it. A list rather than a
count, because `touches[0]` and `touches[1]` are page-wide in exactly the same
way as `touches.length` — the first finger on the screen may be the other
terminal's. `root === null` is the WebView, whose fingers are all its own: the
list is returned untouched, so nothing is allocated on a path that runs at frame
rate.
Census of every `touches` / `changedTouches` / `targetTouches` read under
`src/terminal/document/` (non-test). There are no `changedTouches` or
`targetTouches` reads at all; every read is `e.touches`:
- `tap-dispatch.ts`, 15 reads across the three handlers that take an event
(`[0]`, `[1]`, `.length`, and the list handed to `touchById`): MUST be filtered.
The document listens on `document`, so the event and its list are both page-wide.
- `surface-touch-gestures.ts`, 18 reads across its touchstart, touchmove and
touchend handlers: MUST be filtered. These listeners are on the document's own
surface, so the event is always this document's — but the list inside it is
still every finger on the screen, which is the whole defect.
- `tap-dispatch.ts:21-24`, `touchById(touches, id)`: no filtering of its own. It
reads whatever list it is given, and all three callers now hand it a filtered
one; its parameter widens from `TouchList` to `ArrayLike<Touch>`.
Red-first in `document-host-root.test.ts`, the reviewer's two repros, with the
three product files at `aba99c3e4f` and the artifacts rebuilt: 2 failed / 3
passed (pinch cancel posted with one finger on B's overlay; `terminal-tap` never
posted). With the fix: 5 passed. The pinch-inside-own-host control stays, and the
first repro lands on B's menu pill rather than its surface, because a single
finger on the surface dismisses a selection by design — on the pill, keeping the
selection is the whole assertion.
`document-host-seams.ts` also rewritten in the present tense where it read as
history.
Golden re-pinned: 19 hunks, +51/-33. `touchesInRoot` emitted after
`eventTargetInRoot`; one `const touches = touchesInRoot(scope.root, e.touches)`
at the top of each of the six touch handlers, and every `e.touches` read inside
them now reads `touches`. Document 728,589 -> 729,152 bytes, sha256 1556f532...
-> 02633389...
Correction to `9824145e1f`'s message: it cites `tap-dispatch.ts:241-244` for the
four installs, which at that commit are `261-264` (the line numbers are the
pre-fold ones from the review).
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): fold the document's never-written constants out of the scope
Seventeen scope fields were never assigned after the factory built them. They were fields
because the generator substituted them into one function scope and a module cannot import
into a string; each is now a `const` in the module that owns it.
- `escape-introducers.ts` holds `ESC` and `C1_CSI`, as the bytes rather than as
`String.fromCharCode` calls a JSON substitution needed.
- `write-queue.ts` owns the status dot, its two presentation selectors, the pattern and the
DECSET tail limit. The pattern is a regex literal: a `new RegExp` at a module's top level
is parse-time work, which ruling 20 refuses and the census measures.
- `fit-scale.ts` owns `MIN_FIT_COLS`, `text-scaling.ts` the ends of the preset range,
`tap-dispatch.ts` the press and tap thresholds, `selection-range.ts` the word pattern and
`selection-overlay.ts` the edge-scroll distance and tick.
Four functions stop taking a scope they no longer read: `isStatusDotPresentationSelector`,
`endsWithStatusDotPresentationSequence`, `extractMouseModeScanTail` and, with
`normalizeInitialData`, `isAltScreenActive`.
`runtime-constants.ts` held no constants once they moved out, only the surface element read.
That read is the first line of `startSurfaceSwap` now, which is the module the field is
documented as belonging to, and the start sequence is ten calls rather than eleven.
The engine error buffer becomes a seam, `capturedEngineErrors`. The WebView's `<head>` keeps
its own: it opens before the engine script tag, so an engine that throws while loading is
captured by something no document has started yet, and the first report quotes it. That is
why the head declaration stays where the design said it would go — the page's mount answers
the seam with a buffer per mount instead of assigning a window global, which is what the
document no longer touches.
Two of the page's three casts are gone, and tsc is what says so: xterm's cell attribute
getters answer numbers, and `getLine(...).getCell` answers `undefined` rather than null, so
the shape now describes the engine it was written against. The third stays with a narrower
reason: `getCell` takes back the cell xterm allocated, and describing that parameter means
naming xterm's whole cell type where the document declares the six members it reads.
Comments that narrated the extraction — the scope table's `var` census, "the flip", "the
main slice", "C7.1 extracts" — say what the code does instead.
Oracles that moved with the constants: the reflow floor and the status dot now read the
owning module's `const` rather than a scope-factory line; the write-queue harness resolves
the module's one value import; the parse-time census asserts the stronger fact that no
module does parse-time work, with the element reader aimed at every module and its presence
proved by the planted case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): re-measure the session route's closure for the threaded document
The reading in the closure test's note is against `origin/main` at ec82173130, measured the
same way on both sides: the route and its layout built as their own entries, the route's
output taken minified.
modules 4320 -> 4321 (+1)
local modules 970 -> 971 (+1)
minified bytes 3,768,122 -> 3,764,932 (-3,190)
The +1 is three modules in and two out, which the note names. The bytes fall for two
reasons the lane can point at: a threaded parameter minifies to one character where a
shared object could not, and a constant folded into the module that owns it is inlined
where `scope.X` was a property access the minifier had to keep.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep AsyncStorage out of the WebView document, and prove it
The 13 KB the bundle grew is not esbuild's lazy `__esm` wrappers and not a cycle: there
are none of either. It is a dependency that rode in. `text-scaling` reached
`storage/preferences` for the text-scale presets, and that module imports AsyncStorage, so
the phone's document carried AsyncStorage, `merge-options` and `is-plain-obj` — 11.6 KB of
storage library inside a string with nothing to store, wrapped in esbuild's CommonJS
interop. The old generator hid this: it substituted the presets as a JSON literal, so the
import never reached the emitted text.
The presets move to `terminal/terminal-text-scales.ts`, a leaf with no imports of its own,
which `storage/preferences` imports and re-exports for the settings screen. The bundle:
113,442 characters, 3,465 lines, 47 inputs, none from node_modules
was 120,217 characters with 6 node_modules inputs and three `__commonJS` wrappers
the golden it replaces was 110,085 bytes
`minify: false` stays, for the reason given: the overlay reports the line and column
`window.onerror` hands it.
Three cases join the bundle evaluation, and each was made to fail before it was kept:
- A `set-theme` and a `write` before `init`, which is what a byte golden covered by
accident. Read from the router rather than assumed: the theme applies to the scope and
paints through the seam, the chunk normalises and queues, the pump returns because there
is no terminal, and `init` then resets the queue and the mode scan so the early chunk is
dropped and the init frame's own theme wins. Deleting `resetWriteQueue` from `init` makes
the terminal write `early chunk\x1b[0mreplayed` — the early bytes ahead of the snapshot,
which is the corruption the reset prevents.
- The transport, both ways: the native document installs `message` on `window` and on
`document` and keeps them, because the WebView never stops its document; the page's
factory with a no-op transport installs none, so none of the shell's own frames are taken.
Installing on one target fails the first half, installing a real listener the second.
- What the bundle carries: no input from node_modules, no `__commonJS`, no `__esm(`.
Pointing the presets back at `storage/preferences` fails it with the six inputs named.
The build options become one object the census and the build share, so what is measured is
what ships.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct the closure reading for the text-scale leaf
The presets moving to their own module adds one more to the page's closure than the reading
recorded, and five bytes with it. Measured the same way on both sides.
modules 4320 -> 4322 (+2)
local modules 970 -> 972 (+2)
minified bytes 3,768,122 -> 3,764,937 (-3,185)
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): separate the lane's closure reading from main's
The byte figure in the note was measured before `origin/ota-c7-5b-document-factory` and the
main it carries were merged in. This head reads 3,765,180; the 243 between the two are the
touch-root predicates and main's #21687 momentum change, which are not this lane's to claim
in either direction. Both numbers are named rather than one of them silently replaced.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare the start sequence with what the modules export, by name
Round 1's finding on the census: completeness was a count, so a module exporting a start
nobody calls red only as `expected 11 to be 10` and greened again the moment the literal
moved with it. The two sets are compared by name now, with the order asserted as well: the
starts the factory calls are every exported `start*(scope)` there is, the stops are every
exported `stop*(scope)`, and the stops of modules that have both run in the reverse of the
order their starts did.
`cancelDocumentFrames` is held out of the set comparison and asserted by position instead —
last, after every stop that might still hold a frame. `stopEdgeScroll` is the one exported
stop the sequence does not call, and it is not a lifecycle undo but the overlay's own for a
drag that is over; the test asserts `stopSelectionOverlay` reaches it rather than waving it
through.
The names come from the tree rather than the text, because a regex over the file would also
match the sequence's own name in the unwind inside `startTerminalDocument`'s catch.
Red-first control, `export function startReflow(scope)` added to `reflow.ts` and not called:
the set comparison fails naming `startReflow`, where the count failed with a number. A
second case plants the same shape against the reader itself, so the comparison is a
measurement rather than an agreement between two empty lists.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the bundle census and the artifact off one build
Round 1's finding: the input assertion built the bundle while the wrapper assertions read
the committed string, so under the presets-to-preferences control the rebuild red and
`__commonJS` passed against a stale artifact. And `inputs.length > 40` was a bound, not a
census.
`terminalDocumentBundle()` returns the text and the module list from one build, and
`buildTerminalDocumentScript` is that function's text — so the thing measured is the thing
written. The case asserts the exact input count, no node_modules input, neither wrapper, and
that the committed artifact equals what the sources build. A stale artifact now reds.
Controls: the presets pointed back at `storage/preferences` fails on the inputs and on
`__commonJS` in the same run; `MIN_FIT_COLS` changed to 21 without rebuilding fails the
equality. Worth knowing for the next reader: an unused export or a dropped comment does not
fail it, because esbuild does not emit either — the assertion is about what ships.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): show that the page's capture buffer is written, and say what writes it
Round 1 read the page's `capturedEngineErrors` array as never written and asked for an empty
readonly list instead. It is written, and the empty list would drop a line from every report
the page makes: `startHostNotify` installs the reporter through `installErrorReporter`,
which on the page is a `window` error listener, and the reporter appends each error it
forwards before `reportEngineError` quotes the buffer back. `host-notify.ts` is the file that
proves it; the comment on the mount said none of this and now says it.
The pre-start window round 1 asked about is the half the page genuinely cannot have. The
WebView's `<head>` opens its buffer before the engine script tag, so an engine that throws
while loading is captured by something no document has started; on the page the engine is a
static import of this module, so there is nothing to capture before the document exists.
Red-first: two errors dispatched at a real mount, and the reports quote `captured: first
failure` then `captured: first failure | second failure`; a second mount quotes its own line
and not the first document's. Answering the seam with `() => []`, which is what round 1 asked
for, fails the first assertion.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): put the engine doc block back on the engine seam, in the present tense
Round 1's last two. The block describing how the WebView knows the engine is there had ended
up above `windowCapturedEngineErrors`, one function too high; it is on `windowHasEngine`
again, with that function's own note about the optional global folded in.
Two references that had outlived what they named: the host-seams case cited
`runtime-constants`, which this branch deleted when its one element read moved into
`startSurfaceSwap`, and now cites modules the sequence still has; the query-reply harness
narrated what its oracle used to be instead of what it is.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): reverse the stop order without mutating the list
`Array#reverse` mutates, which the lint rule refuses and which would have left the census
comparing a list it had just reordered. `toReversed` on the filtered copy says the same thing
and cannot.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): put every document import back in its import block
pullfrog caught `fit-scale`, where `scheduleDocumentFrame` sat below `MIN_FIT_COLS`. A
statement walk over all 41 non-test modules under `document/` — the tree, not a grep, so a
multi-line import or one inside a comment cannot hide — found one more: `host-notify` split
its two `document-host-seams` lines around the re-export between them. Both imports moved up;
the re-export stays where it was, below the block, with its comment in the present tense.
The sweep reports no misplaced import across the 41 modules now.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): name the haptics module inside the merged session-closure reading (OTA phase C, C7.5b)
The merge's re-measured 4284 sat one above this branch's -40 added to PR B's +3,
and the comment could only say main had drifted "a module of its own". It is
`src/mobile-web-shell/bridge/bridge-haptics-notify.ts`, which C7.10 item E put on
the session route after PR B recorded 4323 — so pristine main reads 4324 against
the 4323 it holds, which is what #21908 re-pins.
Named here as #21908 names it on main. Nothing measured changes: 4284 is the same
number, and the module is in it by main's route rather than by anything this branch
did. `haptics.web.ts` was already in the closure; the bridge module joins it.
Verified by reading the closure's own module list rather than inferred from the
count: both haptics modules are in `local`, with the artifact-level totals
unchanged at 4284 / 934.
Which is why the reading is re-measured and not summed. A merged number arrived at
as -40 plus +3 would have read 4283 and been wrong about a module neither side of
the merge touched.
After #21908 lands, a further merge of main reconciles the two comments.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop every document a host-seams case started
CodeRabbit's finding, and it measures: `startedScope` runs the whole start sequence and no
case stopped it, so the `afterEach` restored the globals and left the listeners. A start
installs six on `document` and `window` — the dispatcher's four capture-phase touch handlers,
the fit's resize and the recovery's visibilitychange — and those are page-wide by nature, so
a document nobody stopped keeps answering events in the next case, with a scope that case has
never seen and host hooks that belong to the case before it.
Every started scope is tracked and stopped in the existing `afterEach`, before the globals go
back, because a stop reads the scope's own seams and one of them is a window read a case may
have stubbed.
Measured by counting `addEventListener` and `removeEventListener` on both targets across the
file's run: 18 added and 0 removed before this, 18 added and 18 removed after. For a single
case it is 6 and 0 against 6 and 6.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): pair each doc block with the declaration under it
Two comment placements, both mine and both from the merges.
The closure test's `SESSION_ROUTE_MODULES` block opened twice: my conflict resolution kept
the opener that was above the marker and supplied another with the replacement text, so the
file carried a literal `/**` inside the block it opens. Nothing flags that, because it parses
as one comment. Swept the rest of the files the merge touched for stacked openers and for
stray markers; there are none.
In the host-seams case, `startedScopes` and its block landed between `startedScope`'s doc
comment and the function, which left the function undocumented and stacked two blocks on the
const. The const moves above the function's doc, so each comment sits on what it describes.
host-seams 10 tests, the closure test 3, both exit 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(terminal): configure interactive Unix shell args
* fix(settings): clarify Unix shell argument defaults
* fix(settings): improve Unix shell argument guidance
* fix(settings): simplify shell argument guidance
* fix(settings): clarify empty shell args
* fix(settings): explain empty shell args
* feat(settings): make shell argument modes explicit
* fix(settings): keep no args inside custom mode
* fix(terminal): apply configured shell args on the renderer spawn path
The renderer's pty:spawn handler builds options in ipc/spawn-options, not
the runtime controller, so the configured profile never reached a terminal
pane. The local launch plan also dropped the args whenever shellOverride
was set -- which the spawn path always fills from terminalDefaultShell.
Both spawn paths now share one resolver.
* chore(i18n): allowlist the new terminal shell argument strings
Matches how the sibling Terminal shell settings strings are already handled.
securitypolicyviolation is dispatched as a queued task, so its order against
the frame's load is not guaranteed. On the runner's Chrome the list held only
the blocked background image when the arm read it, and the assertion failed on
an entry that had not landed yet; this PR's own run passed by timing.
The header-keeping arm now waits for its own evidence, bounded by the case's
signal like every other wait here, and the assertion is unchanged. A frame
that was never widened raises no such entry at all, so the wait is the arm's
precondition rather than a convenience, and it ends in the rig's diagnosis
rather than a bare hang.
Red-first, isolated to the new wait: with nothing in the artifact for the
policy to refuse, the case fails at its timeout and prints "the frame never
reported a script-src refusal" with the arm, the browser and the frame's own
readings.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(relay): re-place hosts off a cell isolated for a roll
A roll isolates a cell by moving it out of the 'general' admission class; the
cell then refuses every attach with 4503. The director never noticed, because
the only liveness test it applies to a host's current cell reads
`relay_cell_runtime.ready` and the heartbeat, and an isolated cell keeps
heartbeating ready=1 for the whole drain. So every host on that cell was handed
its own dead cell, closed, and handed it back — 500-1,900 hosts looping for
13-16 minutes per cell roll, at ~6 dials each per minute, with no neighbour
absorbing anything.
The sticky lane now treats a live incumbent whose admission is 'migration-only'
— the state a roll's isolate step writes — the same way it treats a dead one:
it returns null, which means "fall through to placement". The placement lane
had the identical hole eleven lines further down, so it takes the same
predicate; without that second swap the sticky change is inert, because
placement would hand the pin straight back (a draining cell has more headroom
than anyone). An isolated incumbent skips the dead-cell fence branch: that
branch exists to prove an unreachable cell stopped serving a host, and this one
is reachable and enforces the epoch itself.
'existing-only' is deliberately untouched — those cells serve the hosts they
already hold, and only `assignmentStrandedOnUnservedCell` may release that pin.
A host with an open `relay_assignment_migrations` row keeps its pin too, so
this stays disjoint from the migration machinery.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(relay): gate re-placement on a roll-isolation marker, not on admission
Review of the first commit found the predicate wrong. `migration-only` is an
admission class, not a drain signal: an Asia `--mode rollback`, an evacuation or
forward-recovery target awaiting a separate promote dispatch, a failed same-cap
wave's re-isolate, an abandoned migration retired on its target and a rehome
settlement all park loaded cells there durably, with no migration lease and no
open migration row. All five were indistinguishable from a roll's isolate, so
the first commit would have converted `operate-relay-asia-admission --mode
rollback` from a reversible admission flip into a mass move of ~4,000 hosts —
and, because `leastLoadedCell` treated region as a preference, into us-central1.
The signal is now an explicit stamp. `relay_cell_admission` gains a nullable
`roll_isolated_at`, added through the shared schema runner's catalog pre-check
so a migrated database takes no relation lock on boot and an un-migrated one
gets a catalog-only rewrite. The same-cap isolate step is its only writer, via a
new optional `rollIsolatedCells` on the selector apply; the same UPDATE that
writes the state clears the stamp whenever a cell leaves 'migration-only', so a
restore cannot leave one behind and a failed wave's re-isolate keeps the one it
has. Every other admission writer omits the field, so its cells stay unmarked
and their hosts stay pinned. Old directors ignore the field; old callers never
send it.
Region is now a constraint rather than a preference on this path only: a
re-placement must find a general, live cell with connection headroom in the
host's own region, or the pin is kept and one
`orca_relay_sticky_replacement_deferred` event is logged. Cross-region spill is
no longer reachable here.
The fence bypass is narrowed to a live incumbent. It was always a no-op for the
intended case, and for a stamped cell that stops heartbeating while still
holding sockets it reopened split-brain; that cell now takes the dead-cell path
unchanged.
Also: the hot-path admission reader no longer throws on an unrecognised state —
it sits on every sticky dial and the rule it feeds is "move the host", so an
unreadable row has to mean "don't". And the sticky lane reads the admission row
once for both the stranded rule and the stamp instead of twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(relay): emit the re-placement events after the transaction commits
CodeRabbit on assignment-store.ts:1075. Both events were written where they are
decided, which is inside assignOnce's transaction. A reservation or lease write
failing after that point rolls the placement back, but a line already on stdout
cannot be rolled back with it — so the canary this PR asks an operator to read
would count re-placements that never happened, and a Postgres transaction retry
could leave a stale line behind as well.
The transaction now returns its events alongside the RelayAssignment and the
caller flushes them once it has resolved. Returning them rather than setting a
variable in the enclosing scope is what makes the retry case safe too: only the
attempt that committed can carry its events out. assign()'s signature is
unchanged; the extra shape lives entirely inside assignOnce.
orca_relay_sticky_replacement_deferred was moved the same way. It cost one more
push into the array that already existed, and it is decided inside the same
transaction, so leaving it behind would have been the odd case rather than the
cheap one.
The new test injects a failure on the first write after the decision, asserts no
event is emitted, and asserts the assignment is still on its original cell —
without that second assertion the absence would only prove the emit was early,
not that it would have been wrong. A control dial with nothing injected emits
exactly one event, so the case cannot pass on a broken harness. With the emit
put back inside the transaction, it fails.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(relay): expire the roll stamp, correct the wire note, assert the stamp landed
Delta review findings B, D and E. A (the deferral path's cost) is deliberately
not implemented; it is now written up under Follow-ups in the PR body as
required before any Asia roll, because it cannot fire in a US canary.
B, which also closes C: the stamp was written, carried and never compared to
anything. A roll isolates and restores one cell inside ~15 minutes, so a stamp
older than two hours is not a roll in progress. It is a failed wave whose
failsafe re-isolated a possibly healthy cell and is waiting on an operator — the
postmortem in this tree records gaps of hours — or an orphan left by a director
rollback whose restore wrote 'general' without the clause that clears the stamp,
which the selector's 'keep' branch would then preserve until some later park
reactivated it. Both want the same answer and it is the pre-existing one: keep
the pin. One comparison against a value already on the row.
The bound takes the caller's `now` rather than reading the clock again, so one
assign reasons about one instant; the stamp's age is now a thing that decides
whether a host moves, and two clock reads could disagree across it.
D: the comment beside the new request field claimed an updated caller reaching
an older director "is simply ignored". The schema is .strict(), so it is a 400.
That fails closed — the isolate aborts before MUTATION_STARTED is set and
nothing is written — but it is a deploy ordering constraint, and it was
undocumented. The comment now says so and the PR body's rollout notes carry it.
E: nothing read the `rollIsolated` the script already prints, so an older script
against a newer director would silently produce today's behaviour and the canary
would read as "the fix did nothing" with no way to tell that from a wrong
premise. Both isolate steps now assert it, beside the generation they already
parse.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
#21733 consolidated the source-control tooltips and added
auto.components.right.sidebar.SourceControl.527e130b6f to en.json without
regenerating en-runtime-required.json, so verify:localization-runtime-catalog
fails on main and on every PR's static-analysis job.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): offer a cancelled top-frame navigation to the shell's opener
Both shells cancelled every navigation off their own document in silence: iOS
`decidePolicyFor` allowed only `isMainFrame && isDocumentUrl`, Android's
`shouldOverrideUrlLoading` dropped anything whose resolved path was not "/".
Nothing opened. That is the whole of ruling 29's "if they do not": a user tapping
a link inside C7.10's sealed HTML-preview frame reaches the top frame as a
navigation request, and the shell was the only thing that could act on it.
A cancelled main-frame navigation now reaches JS as `onExternalNavigation` and
goes through the same `Linking.openURL` the `externalLink` notify already uses.
The scheme list is not restated natively: the native side caps the string and
says which frame it came from, and `readBridgeExternalLinkUrl` decides what opens
in the half that ships over the air. A subframe navigation is never offered,
because that is the sealed preview loading itself.
swiftc check: OK (`checkCancelledNavigation` added, the whole suite runs).
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): render the HTML preview in a sealed srcdoc frame on the page
C7.6 gave the page the artifact's source, which is the native component's Source
tab and half its job (ruling 8). Ruling 26 makes that debt: the Preview tab comes
back as an `<iframe sandbox srcdoc>` inside the page's own document.
`srcdoc` rather than a `blob:` URL, and no CSP change at all. Measured on Chromium
and WebKit: a `srcdoc` frame has no URL for `frame-src` to match and inherits its
embedder's policy instead, so it is admitted under the shipped `frame-src 'none'`,
while a `blob:` frame is refused by `frame-src` on both and refused a second time
in WebKit by the `frame-ancestors 'none'` it inherits.
Two independent fences seal it, and the render check measures each on its own:
the sandbox grants neither `allow-scripts` nor `allow-same-origin`, and the
inherited `script-src 'self'` refuses the artifact's inline script even when a
control arm grants `allow-scripts`. The inherited `img-src` and `font-src 'none'`
govern its subresources, against a no-header control where the same three are
fetched.
`allow-top-navigation-by-user-activation` is the one token granted (ruling 29), so
a tapped link becomes one top-frame navigation the shell now opens externally,
while a `<meta refresh>`, a form submit, `target="_blank"` and any script-initiated
navigation produce none.
`lucideBarrelPlugin` is exported from the bundle builder so the check builds the
toolbar's icons the way the page does rather than carrying a second shim.
config/scripts suite, this file: 14 passed, 0 errors, exit 0. Control runs: a
literal `sandbox` in the JSX reds 4, an added `allow-scripts` reds the script
fence and the token census.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the preview's sealed frame where the degradation was pinned
The three HTML-preview cases in this file described the state ruling 26 retires:
no toggle, no frame, the source only. They now pin the frame's shape through the
test renderer -- the artifact reaches it as `srcDoc`, the sandbox grants neither
`allow-scripts` nor `allow-same-origin`, both toggle positions exist, and Source
takes the frame away with it -- and the "never renders the html itself" case
becomes "never puts it anywhere but the frame", counted rather than merely absent.
What a browser does with that frame stays in the render check, which is the only
thing that can answer it.
The rich Markdown editor's half is unchanged: it is still the plain field, and
item C is a later PR.
Two mocks added: `Pressable`/`ScrollView` on the react-native double, because the
toggle renders one, and `lucide-react-native`, whose barrel imports a
`LucideProvider` its own context module does not export and so does not load under
vitest at all.
9 passed, exit 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse a link-activated top-frame navigation, even to the document
F1, blocking, with F5 and F6 folded in because they are the same decision and
splitting them would mean three rewrites of one function.
F1: `<a href="/" target="_top">` and `href=""` in an artifact resolve against the
embedder's base, so both named the shell's own document URL -- which both shells
ALLOWED (iOS `isDocumentUrl`, Android's path `/`). One tap inside the sealed
preview reloaded the shell's page: bridge target cleared, load state restarted,
page state gone. A navigation a human started is now never allowed, whatever it
names; it is offered instead, and `cancelledShellNavigationTarget` drops
`orca-mobile-web:` in silence exactly as it drops `/h/other`. The page rewriting
its own path carries no gesture and is still allowed.
F5: the OFFER is gated on the same gesture, so a top-page meta refresh or a
redirect is cancelled and never opened externally.
F6: iOS returned early on `shouldPerformDownload` before the offer, so `<a
download>` was dead on iOS and opened on Android. The early return goes; a
download is refused rather than allowed when nothing started it, and a
gesture-started one reaches the opener on both platforms.
The allow half and the offer half are now one function per platform
(`MobileWebShellNavigationPolicy.verdict`, `mobileWebShellNavigationVerdict`), so
they cannot drift. The gesture is the platform's own answer: `.linkActivated` on
iOS, `request.hasGesture()` on Android.
Native tests, both platforms: document URL + gesture refused and offered; document
URL without gesture allowed; foreign + gesture cancelled and offered; foreign
without gesture cancelled and silent; download both ways; subframe never offered.
swiftc OK; control run with the gesture rule removed exits 133. Gradle
MobileWebShellDroppedNavigationTest tests=8 failures=0 errors=0.
Also corrected: the screen comment that claimed the document's own reloads reach
the handler (they never do), and the prop doc, which now states the gesture rule.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count own-origin top-frame navigations, and drop the goto cap
F2: `page.setDefaultTimeout(4000)` capped `page.goto` at 4 s while every sibling
render check uses the 30 s default, so under load the first WebKit cases redded on
the navigation rather than on anything they assert. The cap goes; the per-action
timeouts that needed to be short are already passed at their call sites.
F1's page-side half: the rig now routes the page's own origin as well as the
foreign one and counts main-frame navigations to each separately, with two cases
pinning that `href="/"` and `href=""` each produce exactly one own-origin
top-frame request. Playwright is not the shell, so what these state is the request
the shell is handed; refusing it is the native tests' job and the docstring names
which ones. The own-origin route is registered after the initial load, because it
aborts main-frame navigations and the first `goto` is one.
The foreign-tap and meta-refresh cases now also assert zero own-origin
navigations, so a fix that merely moved the target would not pass.
16 passed, exit 0, no Errors line.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): wait for the preview frame's own load, never a clock
CI read the child frame before its srcdoc committed: frameUrl came back ''
and the control arm's script as not yet run. The frame list, the frame's URL
and anything read inside it settle at their own moments, and a 900 ms wait
reads whichever of them has happened -- on a loaded runner, none.
Polls for a child frame at about:srcdoc with its load fired, bounded by the
case's own timeout, and an override arm now resolves on the document its
srcdoc assignment commits rather than on the assignment.
Red-first: with a 2.5 s mount delay standing in for a loaded runner, the
paint case failed on both engines before this and all 16 cases pass after.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say whose violations the preview rig reads
The list is the main frame's: securitypolicyviolation does not cross into a
frame, so an empty one says the embedder raised none and says nothing about
the artifact's own style, image or font. A listener inside the frame cannot
be the fix -- the fence under test is that nothing in the artifact runs.
So the comment now claims what the reading supports, and names where the
frame's containment is actually measured: the pixel for its inline style,
the counting server for its img-src and font-src.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): announce which side of the preview toggle is showing
The Preview/Source pair carried a label each and nothing else, so which one
was showing lived only in the active background -- invisible to a screen
reader on both surfaces. Each button is now a tab carrying its selected
state, inside a tablist, and the two files' toolbars stay character-identical
so the page and the phone announce the same thing.
Red-first: the new case renders both siblings and failed on both for the
missing role before this.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the WebView mock like the file's other hosts
The anti-slop gate refuses a bare `object` parameter. Takes the same shape as
the react-native mocks beside it, which pass it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): allow only the load the shell itself started
The document URL was allowed whenever the host reported no gesture, so a
navigation the shell never asked for could reload the page out from under the
session. Measured against a real WKWebView off-device: a sandboxed subframe
navigating the top frame to the document URL arrives as `.other` with no
gesture at all, and Chromium's own docs allow hasGesture() to be false for a
request a human started. Census first: nothing in the page navigates the top
frame -- no location assignment, reload, replace, window.open or form -- the
router moves by pushState and replaceState only, so the rule needs no gesture
and no page cooperation.
Both shells now raise a flag around their own load and drop it at commit, and
allow a main-frame navigation only while it is up. Everything else naming the
document is refused and never offered, since offering it would send the user
out of the app. iOS carries the second discriminator the same probe measured:
sourceFrame is the main frame for the shell's own load and the subframe for a
subframe's top navigation, so a subframe can never take the allow path.
Red-first: the Swift checks and the Kotlin tests were written first and failed
to compile against the old signature. 9 Kotlin tests, 54 in the module.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): point the meta-refresh arm at the embedder's own URL
The fixture pointed off-origin, so its own-origin assertion could not move
whatever the frame did. The new arm refreshes to `/`, which resolves against
the embedder's base, and pins zero top-frame requests on a counter the
`href="/"` case proves reads 1 in the same rig.
It also counts what the frame asks for itself, with a presence control that
attributes the fence: with `allow-same-origin` and no policy the same fixture
navigates the frame to the embedder's `/`, and with the policy dropped but the
product's token kept it navigates nothing, so the opaque origin is what
refuses it rather than the CSP.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read what an action produced, not what a clock allowed
The 600 ms after every action is gone. An arm that expects a navigation now
returns the moment the route handler records it, with a deadline only so a
click that missed its target says so instead of spending the case's timeout.
An arm that expects none waits for two painted frames inside the page and one
200 ms drain for the popup queue, which is a browser-process event with no
in-page counterpart; the docstring says why that one is bounded.
Measured and reported rather than claimed: with the new wait replaced by a
no-op every arm still passes, because the reads that follow are each a round
trip. It is insurance against the runner load that produced the frame-commit
race, not a fix for a failure seen here.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): take the settling branch as a ternary
What oxlint's prefer-ternary asks for, and the changed-code gate with it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): find the preview frame by its element, not its URL
CI timed out on all seven preview cases on one engine: the poll waited for a
child frame whose URL reads about:srcdoc, and that browser reports an empty
URL for a srcdoc frame, so every case ran to its own timeout. The same
difference had already shown as `expected '' to be 'about:srcdoc'`.
The frame is now the element: waitForSelector('iframe') then contentFrame(),
with readiness taken from the fixture's own marker inside it. Nothing compares
a frame URL any more -- the paint case reads the element's srcdoc attribute
and the absence of src instead, which is what "parsed inside the frame rather
than fetched into it" actually means. The one arm whose artifact navigates the
frame away says so rather than waiting for a marker that is not coming.
Red-first: with the old poll keyed on a URL the browser never reports, both
engines time out exactly as CI did; the new wait passes 18/18 with the 2.5 s
mount delay still injected.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make a frame that never becomes ready say what it saw
The runner's Chrome read the preview frame's URL as empty where three
chromium builds here read about:srcdoc: bundled headless, the headless shell,
and --headless=old, all 147. So the difference is not reproducible locally and
the next CI run has to carry its own diagnosis.
The marker wait is bounded well inside the case timeout, and on expiry it
reports the frame's URL, the srcdoc attribute's length and the page's CSP
violation list -- which separates a frame the policy refused from one that was
merely slow, the two readings that look identical from a timeout.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): run the containment arms the comment only claimed
The comment said the fixture navigates nothing with the policy dropped and
the product's token kept, but no arm ran it: the control dropped both fences
at once. Both single-fence arms exist now, either of which would hold.
Measured rather than assumed, and one of them is not what the comment said.
The token alone: the navigation never starts, no request, no violation. The
policy alone, with allow-same-origin granted: the navigation does start and
frame-src refuses it, which the embedder reports as its own violation. The
engines differ only in what is left in the frame -- chromium an error page,
WebKit the artifact -- so neither is asserted; what is asserted is that the
request never reaches the server.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse a download that names the shell's own document
The document branch skipped downloads, so `<a href="/" download>` fell
through to the offer path carrying the shell's own URL. Harmless in practice,
because the opener's scheme list drops it, but it contradicted the policy's
own comment and the prop doc, and it left the one URL that must never be
offered reaching the boundary.
The branch now covers a download too: refused, from either frame, gesture or
not, and never offered. A gesture-started download of anything else still
reaches the opener.
Red-first on both platforms: the Swift checks exited 133 and the Kotlin row
failed against the old policy. 10 navigation tests, 55 in the module.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop the own-load flag wherever a document ends
The flag lived beside the load call and had to remember every ending
separately, so iOS missed two: a prop update that fails before it loads, and a
renderer that died. Both left it raised, and a navigation to the document URL
during that window would have been allowed.
It now lives in the load state machine, which every ending already runs
through -- a commit, a failure, a dead renderer, a prop update, a reset -- on
both platforms, so there is nothing left to remember. The view raises it and
reads it, and drops it nowhere.
The Android residual is stated in the policy rather than papered over: between
loadUrl raising the flag and onPageStarted dropping it, a navigation to the
document URL from inside the preview frame would be allowed, because that
callback says nothing about which frame asked and no host discriminator
exists. It needs a generation switch and a tap in that window; iOS closes the
same gap with sourceFrame.
Red-first: the new Swift row failed to compile and the Kotlin row with it.
12 load-state tests, 56 in the module.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): spend the own-load flag on the allow, not on the commit
The flag stayed raised from the load until didCommit, so a second main-frame
action naming the document inside that window was allowed too and replaced the
document. WebKit can decide a second action before the first one starts, so
the commit is too late to be what spends it.
The allow itself spends it now, before the decision goes back, and every
ending still drops it for a load that is allowed and never commits.
Red-first: the new check composes the machine with the policy -- the seam the
flag and the rule meet at -- and failed to compile against the old machine.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop raising an own-load flag Android never consults
WebViewClient's javadoc, verbatim: "This callback is not called for all page
navigations. In particular, this is not called for navigations which the app
initiated with loadUrl(): this callback would not serve a purpose in this
case, because the app already knows about the navigation."
So the flag guarded nothing on this platform and, while raised, was the one
thing that could have let a competing request to the document URL through.
The view passes isShellLoad = false always now, the machine drops the field it
had no raiser for, and the policy comment carries the quote. Nothing reaching
that callback is the shell's own load, so nothing naming the document is
allowed there at all -- which also closes the generation-switch window the
residual named, so that paragraph goes.
No red to show: this is a removal, and the behaviour it leaves is the refusal
the existing rows already pin. What a device proof must check is stated in the
policy instead: a WebView that did route its own load here would have it
refused and the load state would sit at loading. 55 tests in the module.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): settle every arm, not only the ones that tap
An arm with no action read its counters as soon as the frame's marker
appeared, so a zero-delay meta refresh could dispatch after the reading. The
arms that pin zero were the ones relying on it.
Every arm settles now, and what it settles on is what it expects: the sealed
refresh arms take the bounded no-navigation path, and the loose arm waits for
a recorded navigation that is neither main-frame nor foreign -- its own
frame's -- rather than the main-frame wait it would never satisfy.
Red-first: with the settling removed and the refresh moved to 2 s, the loose
arm reads 0 on both engines; with it back, 1 on both, the delay still in.
A 0.4 s refresh passes either way, which is why the finding was invisible.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): wait for what the artifact's script wrote, not for the element
The two-fences control asserts the inline script ran, and the marker element
it waited for exists from parse time, so the arm could read window.__ran
before the script had touched it. Under a loaded runner that reads 0, which is
CI's "expected +0 to be 1" on chromium.
Readiness is now per-arm: 'script' waits for the script's own write, 'load'
for the arm whose artifact navigates the frame away, 'artifact' for the rest.
Red-first: with the inline script's write delayed 1.5 s, the old arm fails on
both engines with that exact message and the new one passes, delay still in.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): bound the rig's waits by the case timeout and nothing else
Two inner deadlines, 20 s and 15 s, were racing the outer one they sit
inside, so a slow runner could fail a case on a number this file picked
rather than on the one the case declares.
Both now run to vitest's own `ctx.signal`, which aborts when the case times
out. On abort the rig prints its reading -- the frame's URL, the srcdoc
length, the violation list, or the navigations it did record -- and lets the
case fail as the timeout it is. Nothing is rethrown from that path: a
rejection raised after vitest has given up on a case has nobody left to catch
it, and an unhandled one fails a run whose every test passed.
Red-first: with the marker selector pointed at an element that never appears
and the case timeout cut to 8 s, the diagnostic prints and the case fails as
`Test timed out in 8000ms` rather than hanging in silence.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): ask a stuck preview frame everything it can still answer
The old diagnostic said only that a frame never parsed, and its violation
list was the top document's -- securitypolicyviolation does not cross frames,
so it said nothing about what the frame itself refused.
It now prints the browser version, the arm it came from, the iframe element's
srcdoc length and sandbox, contentDocument.readyState and contentWindow.href
(which answer for a same-origin arm and report `refused` for an opaque one,
so the arm's own origin is in the log), and every Playwright frame with its
url, name, readyState, body length, marker presence, window.__ran and its own
violations. Per frame, because the page's init script installs the collector
in every frame -- measured on both engines -- and CDP evaluates inside an
opaque frame whose scripts are blocked.
Two corrections that the local probes forced. The reading is sampled while
waiting and printed from the last sample: read at the abort it lost its race
with vitest's teardown and printed nothing at all. And two arms had never been
given the case's signal, so their waits could not be bounded or diagnosed.
The diagnosis moves to its own module because the test file is at its line
limit, and because the bound and the reading it prints are one thing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): build a widened control frame instead of relaxing a live one
A live frame cannot be relaxed. Sandbox flags are fixed on a browsing context
when it is created, and Chrome 152 keeps the original ones through a srcdoc
reassignment while still parsing the new document -- so the control arms that
widened the product's own frame stayed sealed on the runner, and CI read a
script that never ran and a refresh that never navigated. Chromium 147 here
honours the relaxation, which is why it passed locally for a year of runs.
The override now clones the element, sets the sandbox on the clone, gives it
the artifact and replaces the product's frame with it, so the widened flags
are there from creation -- the way the product does it, since React sets the
attribute before insertion and never after. The product's own arms are
untouched: a null override still returns immediately.
And the control can no longer pass for the wrong reason on any engine. The
header-keeping arm now reads the violation raised inside the frame: a
script-src refusal can only happen if the sandbox let the script start, so it
separates "the policy held" from "the frame was never widened", which the old
arm could not. The loose arm pins an empty list beside it, the sealed arm pins
an empty one too, and those three readings are the whole fence story. The
violations come from each frame's own collector, because the embedder never
sees them.
Two diagnostic repairs the local probes forced: the browser version is read
once at open, since asking at the abort printed "browser unknown" in the CI
log this exists for, and the reading is sampled immediately as well as every
five seconds, since a wait that only prints "no reading was taken" says
nothing.
Red-first: with the widening disabled, both engines fail exactly as CI did --
180 s timeouts on the script arm -- and the diagnostic names the arm, the
version and the sandbox it actually had.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): put the toggle's selected state where a browser reads it
CodeRabbit is right, and the browser says so: react-native-web's createDOMProps
never reads accessibilityState, so on the page the tab pair emitted role="tab"
and no aria-selected at all. The test renderer could not see it, because it
reports the props the component was handed rather than the DOM they become.
Both siblings carry aria-selected beside accessibilityState now -- the phone's
screen reader takes the latter, the browser the former -- and the toolbars stay
character-identical.
Red-first, in a real browser on both engines: the rig now reads every
[role="tab"] element's aria-selected before and after the tap, and it read null
for both positions before this line existed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(source-control): consolidate tooltips to avoid duplicates
- Remove native title attributes from buttons that also render Radix tooltips
- Introduce PrimaryActionTooltip wrapper that decides whether to show a tooltip based on context
- Hide pure repeats: enabled Stage All and Create PR have no tooltip since the label already states the action
- Show tooltips only when they add information: disabled reasons, commit shortcut, and remote counts
* Show Create PR intent tooltip to explain the prepare step
The Create PR intent label doesn't convey that clicking it stages,
commits, and pushes before opening the PR dialog. Keep the tooltip
to surface this multi-step operation that users might not expect.
* test(config): name the chunk the handoff check says is absent
The three handed-off cases snapshotted the loaded scripts the moment the
page returned and then asserted nothing new arrived after the click. The
opener's own route chunk can still be in flight at that moment, so under
full-suite load it lands afterwards, counts as new, and reds a case whose
rule held: the notify was posted and the document had not moved.
They assert what the comment always claimed instead -- the target route's
own chunk was never fetched -- resolved from the build's route-to-chunk
map, since the bundler hashes chunk names and there is nothing in a URL
to recognise a route by. Being about one named chunk over the whole run
rather than a delta, it cannot be raced by an unrelated arrival.
An absence needs a presence precondition, so `beforeAll` fails if that
route has no chunk; without it a typo would pass all three.
Red-first control: with the coverage test in `route-handoff.web.ts`
forced to keep the hop local, the chunk assertion alone fails and names
the tasks chunk it fetched. Found by the C7.5b lane.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): judge the absent chunk on every status, not only 200
CodeRabbit: the three absence assertions filtered the 200-only list, so a
request for the tasks chunk answered 404 or 500 left no trace in it and
the assertion passed on a fetch that did happen -- the one shape where a
page that tried to render the target reads as a page that never asked.
They read `jsResponses` now, which carries every JavaScript response with
its status, and a request for that chunk fails the case whatever came
back. The 200-only list had no other reader, so it is gone rather than
left as a second collection to pick the wrong one from.
Re-proved on this form, with the coverage test forced to keep the hop
local: the chunk assertion alone reds and now prints the status beside the
path.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(windows): prune the unpatched conpty prebuild by header, not host arch
`prunePackagedNodePty` deleted the published `prebuilds/win32-<arch>/conpty.node`
only when `electronArch === process.arch`. That proxy stood in for "build/Release
holds an addon this slice can load", and it is false for the arm64 slice
cross-built on an x64 Windows host — a rebuild that DOES emit a correct arm64
addon. That slice shipped the unpatched prebuild.
Nothing loads it today: `verifyPackagedConptyBreakawayMarker` resolves the addon
the way node-pty's loader does, so the patched `build/Release` wins and the
release passes correctly. But the loader swallows every require failure and falls
through, so an AV quarantine or a missing dependency on `build/Release` hands the
pane to that unpatched prebuild — the silent downgrade the gate exists to close,
with the binary still sitting in the package.
Read the PE `Machine` field instead of guessing, reusing `readPeMachine` from the
verifier's `windows-pe-machine.cjs` so prune and verifier ask one question. A
missing, truncated or non-PE `build/Release` reads as unloadable and keeps the
prebuild, which is what the true cross-host case needs: packaging Windows from
macOS leaves no Windows binary in `build/Release`, and removing the prebuild there
would leave the package with no ConPTY at all.
Mutation-proven: restoring the `electronArch === process.arch` guard fails exactly
the two new rows in packaged-node-pty-prebuild-prune.
* docs(windows): note the cross-arch conpty slice is real but not yet built
#21927 removed docs/assets/wechat-qr-group8.jpg and updated README.md, but
the fr, ko and zh-CN translations still referenced it. The README local-link
check fails on main today, so every PR run goes red on the root directory
guard until this lands.
Mirrors what #21927 did to README.md: the group 8 image is dropped and the
copy now points at group 9 only.
* refactor(agent-status): drop two superseded Codex attention workarounds
Codex fires its PermissionRequest hook as decider #1, before its own
auto-reviewer and before the user, so the event never meant "a human is
blocked". #21389 fixed that at the source: the execution host reads the
turn's approvals_reviewer from the rollout at write time and keeps a
reviewer-owned approval in `working`.
Two older reader-side workarounds for the same bug are now redundant.
The launch-argument suppressor guessed auto-approve mode by string-matching
the launch args, then dropped the status row in the reader. It only matched
Codex's bypass flag, and under that flag Codex's approval policy is `Never`,
which takes the Skip path and fires no PermissionRequest at all. When the
user turns on "Approve for me" inside a live session the args never change,
so it never fired for the actually-reported case either.
The Codex-only 1.5s notification quiet window could not do its job: measured
auto-reviews take 3-20s and a human can answer in under a second, so no
fixed constant separates them. Its deferred callback also re-checked
liveness and returned without notifying, so a genuine prompt whose pane went
non-live inside the window was dropped rather than delayed. Codex now
notifies synchronously like every other agent.
Also types the coordinator's completion state from the controller's exported
CompletionState instead of asserting each field, which the changed-lines
casting gate required once those lines moved.
* fix(agent-status): settle transient process-exit evidence
* test(mobile): pin the terminal WebView document byte for byte
The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.
So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.
The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.
Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.
Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare two terminal documents as programs, not as bytes
The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.
It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.
So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.
Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.
Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count each normalisation the move makes, separately
Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.
The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.
Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.
Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.
Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the mouse-report cell a module the page can import
The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.
The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.
Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.
`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.
The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.
The document is untouched, so the byte pin from 3006d8dfdf is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the query-reply gate a module the page can import
The second of the twelve groups, and the one that corrects the scope table's
membership rule.
`terminalDataRepliesEnabled` is written from four places, so the whole-script
census counted it among the 57 variables that cannot stay free across modules.
All four writes are in this group. Once the script is modules, a variable
written only inside the module that declares it is that module's own state, not
the document's, and it stays a `let` there. So the scope object holds what
crosses a module boundary, and the 57 is an upper bound rather than the answer;
the qualifier count the flip commit pins will be lower than the 641 measured
over the single scope, and by how much is a function of where the boundaries
fall.
Two references do cross here and are qualified: the write-queue generation this
group compares against, and the observer-disposal list it pushes onto.
Counts pinned: two qualified references, one `var` to `let`, two one-statement
`if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make reflow a module, and give the generator its own tests
The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.
The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.
Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the keyboard-avoidance metrics a module
The fourth group, and the first that needed a non-null assertion.
`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.
Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.
Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.
The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make WebGL loss recovery a module
The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.
The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.
Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make indirect-pointer scroll a module, and count a fifth class
The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.
Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.
The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.
The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.
Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal surface-swap group into a module
The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.
Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): substitute build-time constants into the emitted document
The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.
The fixture script's TypeScript loader moves beside it rather than being
written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal theme group into a module
The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.
Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.
Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal path-tap group into a module
The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.
Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.
One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.
Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal tap-dispatch group into a module
The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.
Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal mouse-click-drag group into a module
The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.
Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal url-tap group into three modules
The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.
Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.
Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.
Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-mode DECSET scan slice into a module
The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.
Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal message-bridge slice into a module
The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.
The second catch keeps its binding: it names the error and reports it.
Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give the document close its own slice file
The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.
Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal term-observers slice into modules
This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.
A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.
Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-state-and-eviction slice into a module
The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.
Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the smooth-scroll and cell-geometry slice
Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.
Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal write-queue slice into a module
The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`,
which belong to other concerns but sit here because emitted-document order
pins them here; four names stop being externals as a result.
The observer disposal keeps its guard-as-expression form behind a line-scoped
disable: the rewrite the rule asks for is outside every counted class.
Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10,
unbound catches 1, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal fit-scale slice into a module
The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.
Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal init-and-write slice into a module
The slice opens with the already-extracted webgl-recovery group, so its text
is two emissions joined. init() resets almost every field the document shares,
which makes this the densest qualifier site in the script.
The caret options were interpolated from the theme module, so they join
`document-constants.ts` as four exports: a substitution is keyed by name, not
by property path.
One local the document declares and never reads keeps a line-scoped
`no-unused-vars` disable. Removing it would be a different program, which is
the one thing this branch does not do.
Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18,
unbound catches 7, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the runtime-state and text-scaling slice
The document's declaration block, where almost everything it shares is
declared, with the query-reply and surface-swap groups interpolated inside it.
Three modules: the two declarations that come before the groups, the text
scaling, and the viewport transform with the scroll indicator. Seven more
names stop being externals.
Two things this slice forced.
The scope-declaration rule now counts each declarator of one `var`, because
`var panX = 0, panY = 0` becomes two assignments onto the scope. It has its
own acceptance case in the instrument's test.
The two halves are compared against their own text rather than as one joined
program. The declaration the slice opens with is shadowed by a parameter
inside one of the interpolated groups, and printing the baseline as one
program renames that parameter; qualifying the outer name removes the shadow,
so the rename has nothing to correspond to. Splitting the slice on the group
constants compares like with like, and those groups have their own tests.
Build-time constants are now substituted textually rather than through an
esbuild `define`: a `define` whose value is an object or an array is injected
as a helper binding instead of being inlined.
Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38,
rebindings 25, braced bodies 13, unbound catches 1.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the two test files the last commit left unformatted
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-report and scroll-routing slice
Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.
Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the host-message-router slice into modules
Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.
The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.
Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-overlay slice into modules
Two modules after the already-extracted path-tap and url-tap groups: the
selection range with the xterm mirror, and the overlay positioning with the
edge scroll. Six more names stop being externals, which leaves one.
Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63,
unbound catches 9, number properties 6, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the surface-touch-gestures slice into modules
The last of the thirteen slices. Two modules after the three already-extracted
groups: the selection menu's buttons, and the touch gestures with the pinch
and the momentum scroll. `attachSurfaceEventHandlers` was the last external,
so `document-externals.ts` is gone: every name the document uses now resolves
to a module.
The instrument reads both sides strict. A loose script has to defend Annex B's
block-scoped function declarations, and the printer does that by hoisting a
`var` and renaming the function, so one side carried a rename the other could
not. Neither name escapes its block, so the two readings agree on behaviour
and only the strict one can be compared. It has its own acceptance case.
Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57,
unbound catches 2, number properties 2, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the document's opening declarations into a module
The document shell carried the IIFE opener and the eight declarations inside
it, so it splits the way the message-bridge slice did: the shell keeps the
HTML and the opener, a new slice file holds the declarations, and the byte pin
proves the join is unchanged.
With this every line of the document's script has a module behind it.
Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the whole document script against the modules
Every line of the script now has a module behind it, so the whole thing can be
compared at once. This is the review of the move, as one number per class:
qualifier 609 references + 73 declarations = 682 sites
var rebindings 373, the document's 446 declarators less those 73
curly braces 279, the number measured before any of this started
unbound catches 36 of 38; two name their error and report it
Number properties 17, also measured up front
shorthand properties 4, two SGR flags written twice each
unshadowed names 7
A seventh class was needed and is counted like the others: a binding that
shadowed a document variable stops being a shadow once that variable moves
onto the scope, so the printer stops disambiguating it. It has its own
acceptance case.
The module order lives in one file that both this test and the generator read,
so neither can drift from the other.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): keep only the lint directives that do something
Seventeen of the disables were inert: `typescript/no-non-null-assertion` is
not enabled here, and a directive naming two rules on one line is not parsed
at all, so the one rule that did apply was being ignored too. The changed-code
quality gate reports an inert directive as a finding.
The two that matter are back, one rule per line: the guard-as-expression in
the observer disposal, and the local the document declares and never reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): generate the terminal document from its modules
The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.
The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:
qualifiedReferences 609
scopeFieldDeclarations 73
rebindings 373
bracedBodies 279
unboundCatches 36
numberProperties 17
shorthandProperties 4
unshadowedNames 7
Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.
Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.
Pattern changes, old -> new.
terminal-webview-scroll-routing.test.ts
var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y;
smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
-> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break
}, { capture: true, passive: false }); -> { capture: true, passive: false }
function momentumStep() -> let momentumStep = function()
pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame(
pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0;
cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId);
var writeQueueHead = 0; -> scope.writeQueueHead = 0;
writeQueueHead++; -> scope.writeQueueHead++;
writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX
getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale("
var FRICTION = 0.972; -> const FRICTION = 0.972;
var MIN_VEL = 0.012; -> const MIN_VEL = 0.012;
edgeScrollDir = dir; -> scope.edgeScrollDir = dir;
term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir);
// Latching document-level touch dispatcher -> function attachSurfaceEventHandlers(
edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX;
edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY;
return mode !== 'none'; -> return mode !== "none";
var pixelX = cell.x; -> const pixelX = cell.x;
var pixelY = cell.y; -> const pixelY = cell.y;
...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) {
...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) {
if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress;
if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press;
if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return "";
document.addEventListener('touchend' -> document.addEventListener( "touchend"
}, { capture: true, passive: true }); -> { capture: true, passive: true }
notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
document.addEventListener('touchstart' -> document.addEventListener( "touchstart"
var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });
terminal-webview-text-zoom.test.ts
var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT =
var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
\n\n function enqueueWrite -> \n function enqueueWrite
var terminalFontFamily = -> scope.terminalFontFamily =
output = terminalFontFamily; -> output = scope.terminalFontFamily;
String.fromCharCode(0x23fa) -> String.fromCharCode(9210)
TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2)
term.open(surface) -> scope.term.open(scope.surface)
term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11"
enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData)
fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily
fontWeight: '300' -> fontWeight: "300"
fontWeightBold: '500' -> fontWeightBold: "500"
terminal-webview-engine.test.ts
var webglAddon = null; .. var webglRecoveryTimer = null;
-> the refreshTerminalSurface()..init( block, with the scope preamble
window.addEventListener('resize' -> window.addEventListener("resize"
'terminal init failed' -> "terminal init failed"
'terminal message failed' -> "terminal message failed"
var everReady = false; -> scope.everReady = false;
everReady = true; -> scope.everReady = true;
fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal
msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady
/fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/
if (msg.type === 'ping') -> if (msg.type === "ping")
notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id })
terminal-webview-reflow.test.ts
} else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2)
var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20;
if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width' -> flog("measure-skip-small-width"
notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... })
var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle"
window.addEventListener('message' -> window.addEventListener("message"
terminal-keyboard-avoidance-webview.test.ts
\n // reflow() -> \n function reflow(
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
\n var panX -> \n scope.panX
TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document
terminal-webview-query-reply.test.ts
attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2)
term.attachCustomKeyEventHandler(function() { return false; })
-> term.attachCustomKeyEventHandler(function() { \n return false; \n });
term.textarea.readOnly = true -> term.textarea.readOnly = true;
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
terminal-webview-url-tap.test.ts
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): delete the slice constants and injected fragments
The document is generated from its modules now, so the strings it used to be
pasted together from are dead. Deleted: the fourteen slice constants under
`terminal-webview-html/` (host-message-router, message-bridge,
mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants,
runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-touch-gestures,
term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write,
write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`,
`document-close.ts` and `theme.ts` stay: the shell and close are still the
document's HTML, and `theme.ts` is where `document-constants.ts` reads the
palette from.
Ruling 17, second commit. Tests that asserted the extraction mechanism itself
went with it: they compared one module's emission against the slice text it was
extracted from, and the flip test now pins the whole document against the whole
pre-flip script with the same eight classes. Deleted, all under `document/`:
fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge,
mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing,
mouse-report-cell, path-tap, query-reply, reflow, runtime-constants,
runtime-state, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures,
tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery,
wheel-scroll. `document/url-tap.test.ts` stays: it pins against
`URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and
still has a consumer.
Tests that asserted behaviour through a deleted string now read the generated
document. `document/generated-document-region.test-support.ts` is the one way in:
`documentScopePreamble()` returns the scope object the document opens with, and
`generatedDocumentModule(name)` re-emits a module and refuses unless the document
carries that text verbatim, so an evaluated block is the WebView's own bytes. The
two local copies of the preamble in the engine and text-zoom tests were folded
into it.
Moved, with every assertion kept and the `expect` count per file unchanged:
terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34
terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14
terminal-webview-query-reply.test.ts 14
terminal-path-tap.test.ts 25
terminal-webview-url-tap.test.ts 33
terminal-keyboard-avoidance-webview.test.ts 18
terminal-webview-reflow.test.ts 22
terminal-webview-text-zoom.test.ts 59
terminal-webview-engine.test.ts 49
Pattern changes, old -> new.
terminal-webview-reflow.test.ts
if (!term || isAlternateBufferActive()) return;
-> if (!scope.term || isAlternateBufferActive()) {
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
var wasAtBottom = buffer.viewportY >= buffer.baseY;
-> const wasAtBottom = buffer.viewportY >= buffer.baseY;
term.scrollToBottom(); -> scope.term.scrollToBottom();
if (nextCols === term.cols && nextRows === term.rows) return;
-> if (nextCols === scope.term.cols && nextRows === scope.term.rows) {
The other eight files kept their patterns; only the text they read changed, from
a deleted constant to the document block. The harnesses that evaluate a block now
build the document's scope object instead of declaring the vars it replaced, and
hand the terminal in as `scope.term`.
Controls, one per file: the module line an updated pattern guards was removed,
the document rebuilt, and the test run. All red, and the tree restores green.
query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed
path-tap const parsed = parsePathLineCol(...) -> path-tap test, red
keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed
osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed
terminal-theme scope.term.options.minimumContrastRatio = ...
-> theme test, 4 failed
write-queue scope.writeQueue[scope.writeQueueHead] = undefined
-> write-queue test, 4 failed
`document-scope.ts` docstrings named the slice each field belonged to; they name
the owning module now. Three module comments pointed at deleted injected files
and point at the modules instead. Neither changes the document: esbuild drops
comments, and the byte pin is unmoved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the right number of counted classes
The flip test's title still said seven; the table it asserts has eight.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the shape applyTerminalTheme writes through
The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test.
`applyTerminalTheme` touches exactly two slots on the terminal it is handed, so
`terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and
the test's parameter and both fixtures use it. The theme is optional on the way
in because `applyTerminalTheme` is what writes it.
No cast. The type is erased by the generator's transform, so the document is
unchanged and the flip test's class table and the byte pin both still hold.
Control: restoring the `object` parameter reproduces the finding at
terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type
it exits 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip pin, leaving the byte golden as the document's fence
`terminal-document-flip.test.ts` compared the emitted modules against
`terminal-document-pre-flip-script.txt`, the hand-written script as it stood before
C7.1, and held exactly while no module changed. That is the proof of the flip, not a
standing fence: the first lane that must change a module has to retire it or restate
its counted classes for a reason that has nothing to do with the move.
C7.5 is that lane — the document's host seams become scope fields so the page can set
them — so both go here, while the test is still green. The flip proof lives at
51ae7b1b03 ("test(mobile): name the right number of counted classes"), which is where
anyone reviewing the move should read it.
From here the standing pin is the whole-document byte golden,
`terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by
the payload-hash digest beside it. Regenerating it is a review event: the emitted diff
is listed old to new in the commit message and in the PR body, and a golden that moves
without a listed diff is a blocking finding.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the terminal document's host seams a field on its scope
Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a
terminal `notify` through it would post raw terminal JSON into the bridge's channel,
and there is no engine IIFE hanging `Terminal` and the two addons off `window` because
the page imports xterm. Four reads had to become seams:
host-notify.ts notify() -> scope.postToHost
viewport-transform flog() -> scope.postToHost
terminal-init.ts new Terminal(...) -> scope.createTerminal
terminal-init.ts window.Unicode11Addon-> scope.createUnicode11Addon
webgl-recovery.ts window.WebglAddon -> scope.createWebglAddon
Each default is the window read the site already did, still performed at call time and
not captured when the scope is built, so inside the WebView the program is the one it
was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object,
because the scope's defaults are those functions and the factory runs as the script is
parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's
file, which the four fields pushed over the 300-line cap; document-scope re-exports
them, so no importer moves. The page's side of the seam lands in C7.5's later commits.
Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside
the `try` it was built in, so a constructor that throws is still swallowed; and no
WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`,
which is the DOM-renderer fallback rather than a failure.
Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document
723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else:
+ (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } }
+ (new) function createEngineTerminal(options) { return new Terminal(options); }
+ (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; }
+ (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; }
- " pendingTerm: null"
+ " pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon
- flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }"
+ flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });"
- " if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {"
+ " if (!scope.term) {"
- " addon = new window.WebglAddon.WebglAddon();"
+ " addon = scope.createWebglAddon();" then " if (!addon) {" / " return false;" / " }"
- " scope.term = new Terminal({"
+ " scope.term = scope.createTerminal({"
- " if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / " try {" / " scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / " } catch {"
+ " try {" / " const unicodeAddon = scope.createUnicode11Addon();" / " if (unicodeAddon) {" / " scope.term.loadAddon(unicodeAddon);" / " } catch {"
- notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }"
+ " scope.postToHost(msg);"
Nothing else in the document moved: the emitted indentation, statement order and every
other literal are byte for byte what they were.
Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the
new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions
and aimed them where the text now is: `window.WebglAddon.WebglAddon` and
`new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on
the recovery module, and the recovery module is asserted to call
`scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope
before the globals exist to show the defaults read the window when they post, shows
each addon factory answering null when the engine has none, and drives a host message
in and a notify out with all four fields set, asserting the bridge is never touched.
Red before this commit at 6 of 7 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): write the xterm stylesheet as its own generated artifact
The page mounts xterm itself, so it needs the engine's stylesheet and must never
resolve the engine string: 612 KiB of minified IIFE built to be injected as text into
a WebView document, unusable under the shell's `script-src 'self'` with no nested
frame to load one into, and the largest single module the session route's closure
would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of
the CSS pulled the string in behind it.
`build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts`
beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment
strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside
its neighbour and written by the same postinstall step, so a fresh tree gets both or
neither. `document-shell.ts` takes the CSS from the new module and the engine string
from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold
both constants read them from their new homes.
The document did not move: `terminal-document-golden.txt` is byte for byte what the
last commit left, 105,968 bytes, and the payload digest is unchanged.
The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks
every module under `src/terminal/document/` as an entry point — the document is one
script whose modules reach each other by side effect, so no single one of them roots
a graph holding the rest — and asserts the engine string is in none of their closures,
with two modules named as the precondition that the walk resolved anything at all. The
native document's own closure is asserted to still hold both generated modules, so the
first case cannot pass by the CSS having gone missing. And the third case plants a
document module that imports the engine string in a scratch tree and shows the walk
reports it, which is what makes the absence above a measurement.
`mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes
the entry points and an optional working directory; the route closure's own two entry
points and its extensionless-specifier reason are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the dead URL-tap constant and two stale reflow guards
Round 1 fixes, all three folded here.
1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with
`document/url-tap.test.ts` deleted alongside it. The document is generated
from its modules now, so that constant was a second copy of the URL-tap group
with no consumer but its own tests. terminal-webview-url-tap.test.ts's
resolver harness reads the document's own text instead, the path-tap,
url-tap, osc-link-tap and surface-tap modules in document order through
`generatedDocumentModule`, which refuses unless the document carries each
verbatim. Its 33 expects all stay. One mechanism-only assertion went with the
file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts`
pin of the three emissions against the constant, which the flip test's
whole-document pin already covers. The file's other exports stay.
The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts
concatenated terminal-webview-url-tap.ts into its `source`, and its
`notify({ type: 'terminal-tap' });` assertion was matching the constant's
single-quoted text, not the document. The read is dropped, since nothing else
in that file needed it, and the assertion is the document's form:
notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" });
Its 95 expects stay. Leaving the read in place would let a document assertion
pass against a module source, which is the hazard this lane exists to remove.
2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer
exists, so it could not fail:
expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
-> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)
Same intent against the generated document: the reflow module's emitted text
is in the document exactly once. The case is renamed to say so and the
comment above it describes the generator, not the deleted template.
3. Same file, the routine assertion still passed as a substring of the qualified
call; qualified as line 30 already was:
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
Its 22 expects stay.
Controls, each verified to have changed the file first, all red, tree green
after restore:
osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed
surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
module order 'reflow' listed twice -> reflow test, expected 2 to be 1
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): mount the terminal document in the page instead of a WebView
`react-native-webview` has no web build that renders anything: measured, it paints the
line "React Native WebView does not support this platform" where the terminal was. So
the page mounts the document itself — xterm imported from `@xterm/xterm` with the
unicode11 and webgl addons, and the document's own modules imported in the order the
generator emits them — behind the identical `TerminalWebViewProps` and
`TerminalWebViewHandle`.
Written as one implementation, not two. `use-terminal-webview-controller.ts` is
everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the
readiness handshake, the pending queue, the write coalescer, the notify dispatch and
the whole imperative handle. Its two arguments are the difference between the hosts —
a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to
re-prove the document with a ping. The native component posts across the bridge and
answers yes on iOS; the web component calls `handleMsg` and answers no, because its
document is the page's own modules and there is no second content process to lose. A
second copy of that file is the fork the series exists to avoid, since the handle is
the contract every consumer holds.
`terminal-webview-ready-promises.ts` carries the two promises the handle hands out,
`awaitReady` and `measureFitDimensions`, which the controller's length made a module.
`document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out
of the document shell; the shell composes them and the golden is byte for byte
unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the
caret options and nothing else, so the page resolves no document string and no engine
string.
`terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the
stylesheet and the markup, sets the four scope seams, and reaches the modules by one
dynamic import — they read their elements as they are parsed, so a static import would
hoist above the planting and leave every one of them holding null.
`page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19
because on the page those `message` frames belong to the shell; its one non-bridge
duty, the window-resize refit, is re-armed by the mount.
`page-document-module-order.test.ts` holds that list against the generator's own,
so a sorted import list or a module added on one side cannot pass.
Two page-side degradations, both bounded and both stated. The document assigns
`window.onerror` as it is parsed, so while a terminal is mounted page errors reach its
reporter; the mount restores the previous handler on dispose. And a browser that
refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery`
already has for a context loss, with a `[fit]webgl-unavailable` notify saying so
rather than a silent halving of the drain rate.
`terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it
scans `src/session` and the terminal directory for an import of the component file by
name, of `terminal-webview-html`, of either generated engine module or of anything
under `document/`, finds none outside the component and its mount, and shows on
planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs`
gains the component's own closure: `TerminalWebView.web.tsx` and
`terminal-webview-html.web.ts` are in it, the engine string, the native HTML module
and `message-bridge` are not.
Four source greps follow the code into its new home, every assertion kept:
`terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the
controller, and reads the two lifecycle clears once in `resetReadiness` plus both
WebView callers in the component; `terminal-webview-reflow` and
`terminal-webview-scroll-routing` read the handle in the controller and the two timers
in the promises module (`measureResolveRef.current === finish` -> `measureResolve ===
finish`, `void p.finally` -> `void pending.finally`).
One behaviour was nearly lost and is pinned by an existing case: the native
foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render,
so the transport asks a predicate rather than carrying a boolean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): render the page's terminal in a browser under the shell's policy
Everything below the contract is new on the page: xterm is an import rather than a
612 KiB string in a WebView document, the document's modules run in the page's own
realm, and the elements they read by id are planted by the component. No module test
settles whether that opens at all under `script-src 'self'` with neither
`unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the
buffer intact.
Three cases in the C6 render harness, against the bundle built by the real builder and
served under the policy parsed out of the shell's own Kotlin constant.
The stream is built for the grid rather than committed: an SGR colour change per cell,
an erase-to-end and an absolute cursor position per row, run out past the host's own
48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the
document's own path — select all, then the Copy button the overlay carries — so the
oracle is the component's `onSelectionCopy` prop and not a private reach into xterm:
6,133 characters, both edge markers present, and no escape byte or SGR text left in
them, which is what says the parser consumed the stream instead of printing it.
The second case takes a fit through the handle, which on the page is a command in and
a notify back with no bridge between, and carries design §8's cheap half of the IME
question. It first pins something that changes where that probe can even point:
xterm's own textarea is inert by the document's design — `query-reply.ts` makes it
read-only, untabbable and `inputmode=none` so touch and hardware keys go to the
screen's input — so text entering a terminal on the page arrives at a `TextInput`, and
that is what is typed into. Chrome reports `insertText` with `isComposing` false for
each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft
keyboard is the device step and this does not claim to answer it.
CSP violations are counted with a `securitypolicyviolation` listener installed before
anything else runs, which is stricter than the console-error filter the other render
checks use — and the first thing it found was not the terminal's. The page entry
carries Zod, whose `new Function` probe is swallowed by its own catch, so
`script-src: eval` is refused once on any page route with no page error and no console
line. The first case is the control that names it, on a route that mounts a marker and
no terminal; the two terminal cases subtract it and report zero of their own. Zero
page errors and zero console errors besides.
No route serves this screen until C7.7, so the component is bundled through a scratch
route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx`
exactly as a real route would. That step retires when the session route is registered.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): retire the last module concatenator and guard the order list
Round 2 fixes, all five folded here.
1. Deleted terminal-webview-html-source.test-support.ts.
`readTerminalWebViewHtmlSource()` had no consumers left once the behavioural
tests moved to the generated document, and it was the last thing that built a
document-shaped string by concatenating module sources — its filter admitted
`.test-support.ts` files too, so it could have grown one. Confirmed by grep
that the only occurrence of either name in the repository was its own
declaration.
2. New document-module-order.test.ts asserts both directions: the non-test,
non-test-support `.ts` files under `document/` are exactly
`{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`,
and no name is listed twice. `document-constants` is the one exception
because it is never emitted: its exports are substituted into the modules
that import them as literals, so the document carries its values without
carrying the module. A module added here and forgotten there would be dead
code that reads as live; a name left after its file goes makes the generator
throw at build time rather than at review time.
3. terminal-document-flip.test.ts's docstring now carries the retirement policy
from ruling 18: the test is the proof of the flip and holds only while no
module changes, the first lane that must change one retires it together with
`terminal-document-pre-flip-script.txt`, and the standing pin from then on is
`terminal-document-identity.test.ts`, whose fixture regeneration is a review
event. Comment only.
4. terminal-document-equivalence.test-support.ts said 57 reassigned variables
and "Four classes and no others". It now says 73 declaration sites and eight
classes, with each class's measured figure named. Two doc comments sat above
the wrong declaration and were moved onto what they describe: the
`NUMBER_GLOBALS` one down to that constant, and the printing one down to
`significantTokens`, with `STRICT_DIRECTIVE` given its own line.
5. build-terminal-document-script.mjs substituted constants with
`replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a
constant's value are read as replacement patterns. The substitution is now
`substituteDocumentConstants`, exported so it can be tested directly, and
replaces with a function.
Controls, each verified to have changed its input first, all red, tree green
after restore:
plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module"
drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll"
revert to the string replacer -> 4 failed, "a $& b" became "a marker b"
The `$n` case is deliberately absent from that table: the pattern has no capture
group, so `$1` is already literal under either form and a case for it could not
tell them apart.
The document did not move. The byte golden, the digest and the flip test's class
table are all unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): measure what the page terminal costs the session route's closure
The session route is not served on the page until C7.7, but the closure the bundler
would walk is the same one and the terminal is the largest thing in it. Measured
against this branch's base, `ota-c7-1-terminal-document` at 51ae7b1b03:
modules 4316 -> 4363 (+47)
local modules 927 -> 971 (+44)
minified bytes 3,930,787 -> 3,883,532 (-47,255)
The route gets smaller. It sheds six modules — the native component, the 612 KiB
engine string, the 105 KiB generated document script, the HTML module and the shell
and close around it — all string literals of a program the page cannot run, and gains
fifty: the component, its mount, the stylesheet and markup modules, the two the
controller split made, and the document's own thirty-nine, with xterm and the two
addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts`
is not among them: it declares types and esbuild emits nothing for it.
The census pins the trade in both directions, because "the engine string is absent"
passes just as well on a closure that resolved nothing: the six shed modules are
asserted gone, the eight gained ones and the three xterm packages asserted present,
and the document asserted whole except `message-bridge`, which ruling 19 keeps off the
page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved
styles — since the terminal's modules joining this closure is exactly the change that
could add a tenth unread.
The page-closure families were run before and after on the full corpus, never a
filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879
include the four page-closure pins, which assert the verdict of every golden C1, C2,
C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an
unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed
and 773 skipped. No family moved, which is what an inert lane should show: this
branch changes no RPC, no opcode, no grant and nothing the recorder reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): clear the changed-code gate findings this lane introduced
Eleven findings from `check-changed-code-quality.mjs` against the base, all in code
this lane added, none of them a behaviour change.
Two type assertions lost their directive to the formatter. The xterm `Terminal` cast
sits on the second line of a wrapped arrow body, so a directive above the assignment
aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon
cast had no directive at all. Both keep the same `SAFETY:` rationale on one line,
which is the only shape oxlint reads.
Two more assertions in `host-seams.test.ts` are gone rather than annotated. The
terminal double's `element` is a getter over a local the double's own `open` writes,
and `withSeams` reads each field it is about to overwrite through
`getOwnPropertyDescriptor` instead of indexing the scope with a cast.
Then three `eslint-disable no-console` directives that disabled nothing, an
`oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the
reason it carried stays as a comment, since the dependency list is still deliberate —
and one duplicated `node:fs/promises` import.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): name the closure helper what main already named it
A trial merge against `origin/main` conflicts on this function: main grew the same
generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with
`mobileWebAppRouteClosure` delegating to it and three callers in the page-closure
families census. This branch is based on `ota-c7-1-terminal-document` and so cannot
merge main, but it can stop being a second spelling of the same thing.
Taken over wholesale: main's name, its parameter, its extension stripping and its
comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main
already has. The only addition is an options bag carrying `absWorkingDir`, which the
engine-closure census needs to plant a module in a tree of its own and show the walk
would report it; the real measurements never pass it. What was a whole-function
conflict is now that one hunk.
The census case that measured the native document had named
`terminal-webview-html.ts` with its extension, which main's stripping does not allow.
It names `terminal-webview-html/document-shell` instead — the module that actually
reads both generated ones — which is the better probe anyway and needs no extension
to resolve, since it has no `.web` sibling.
`web-overrides.json` also conflicts and is left alone: both sides append entries to
one list and the resolution is mechanical.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): put the two closure helpers in main's order
The previous commit took main's name and signature but left the route closure below
the module closure, where this branch had written it. Git merged both orderings and
produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint
reports as a duplicated export — a red the trial merge found and neither side's own
lint could.
Same order as main now: the route closure and its docstring first, the module closure
under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the
merged tree lints clean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the flip comparator refuse what it was accepting
Round 2 items 6 and 7, both in the equivalence instrument.
6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving
the two were the same binding, so an unrelated rename ending in a digit would
have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`,
an explicit list of pre-flip name, generated name and declaring module. The
whole script has one entry: `term2` -> `term` in `query-reply`, which is the
`term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven
sites in all. That is stated in the docstring rather than encoded as a second
pin, since the flip test already pins the total.
7. Brace absorption treated every unexpected `{` as a linter-added body and
absorbed any later `}` while one was outstanding, so a bare block anywhere
would have been swallowed. `isBraceableHeadBody` now requires the open to be
the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its
`(` and reading the keyword before it — and `matchingCloseIndex` records the
index the close must appear at, so the absorbed `}` is that body's own.
That check had to move ahead of the equality check. Wherever a braced body
ends a block, the baseline's next token is a `}` as well, so pairing them
would consume the wrong one and leave the counts right for the wrong reason.
Both refusals are tested over snippets:
function f() { return value2; } vs return value;
-> token 6: expected name value2, generated name value
let value = 1; use(value); vs { let value = 1; } use(value);
-> token 0: expected name let, generated {
and the braceable heads are tested one by one, `if`, `for`, `while`,
`if`/`else` and `do`, so the new rule is shown to accept every shape the `curly`
rule produces and not only the one the document happens to exercise.
Controls: restoring the shape rule fails the first refusal case and nothing
else; restoring the accept-any-brace rule fails the second and nothing else.
The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7.
Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The
tightened rules put the file over the 300-line cap, and a `max-lines` disable is
forbidden, so the token reader moved to its own module: that side answers what a
script says, and says nothing about which differences between two of them are
allowed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(config): take main's docstrings for the two closure helpers
The order matched but the prose did not, so the trial merge still conflicted on the
whole block. Both docstrings are now main's own text, with one sentence trimmed: main
names `MobileBrowserPane` as the first component with a pin of its own, which is C6's
fact and not one this branch can assert.
What remains between this branch and main in this file is the `absWorkingDir`
parameter, which is what the engine-closure census plants a module with.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): write the page terminal's notify sink in an effect, not during render
React Doctor's one error on this branch, and a real one: `receiveRef.current = receive`
ran during render. React may replay or discard render work, so a mutation made there
can leak from UI that never commits — and this ref is read from a callback the mounted
document keeps, which outlives the render that installed it.
Moved into its own effect, declared above the mount effect so the first read already
sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0.
Found late because the first run of that gate was read through `| tail`, which reports
the pipeline's last command rather than the gate's own exit code.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): teach C7.1's order guard the three modules this lane added
The guard C7.1 landed says the document directory and the order list name the same
modules. On this branch three files are in that directory and not in that list, so it
was red on the merge — which is the guard working, and the fix is to name each of them
with its reason rather than to loosen the scan.
document-host-seams emitted, but ahead of the scope rather than inside the order
list, because the scope's defaults are its four functions and
the factory runs as the script is parsed
document-terminal-shape types only; esbuild emits nothing and an empty emission
would add a blank line to the document
page-document-modules the page's entry, not the WebView's, holding the same order
for a host that has no generator to splice them
Named one by one, not filtered by a pattern, so a fourth cannot join them by looking
similar. A third case asserts the seams module is neither in the order list nor the
scope module, which is the ordering the first two cannot see.
Red before this commit: C7.1's version of the file on this tree reports
`document-host-seams` and the other two as directory modules the list does not name.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): re-measure the session closure against the merged C7.1 base
Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved
from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this
lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`,
a module that enters this closure only once the page's component reaches it, so the
saving shows on the after side and cannot show on the base. Both readings are recorded
with the commit each was taken against, because a number with one base named and
another used is the kind of thing a reviewer cannot check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip comparator with the pin it was built for
The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts`
went in C7.1's own round-1 fold at 8da7680c9b, and `terminal-document-flip.test.ts`
went in this lane's first commit under ruling 18, because the flip pin holds only
while no module changes and C7.5 is the lane that changes them. What was left was a
tool, its token reader and a test of the tool, answering to nothing.
So `terminal-document-equivalence.test-support.ts`, the
`terminal-document-tokens.test-support.ts` C7.1 split out of it, and
`terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on
the comparator — bounding an absorbed body to one statement, and refusing a bare block
as `use();` against `{ use(); }` — since there is no comparator left to tighten. The
standing pin on the document is the whole-document byte golden, which is a stronger
claim than token equivalence ever was: it admits no normalisation at all.
`document-module-order.test.ts` gains the case its exception list was asserting in
prose. `document-terminal-shape` is not in the order list because esbuild erases a
module of type declarations to the empty string, and emitting it would put a blank
line in the document rather than a program; that emission is now measured and pinned
as `''`. If the module ever declares a value the case goes red and the module belongs
in the order list with its own line in the golden diff.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): make the document's error reporter the sixth host seam
Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which
inside the WebView is taking nothing from anyone — that document owns its page — and
on the page is a guest displacing whatever the host installed. Restoring it on dispose
was a patch over the takeover, not an answer to it: while a terminal was mounted, every
page error still went to the terminal's reporter.
So `scope.installErrorReporter` joins the five, with today's assignment as its default.
`host-notify` hands it the same handler it always installed, and the WebView's document
is the program it was.
The page supplies its own: an `error` listener that adapts the event to the reporter's
arguments, added on mount and removed on dispose, and `window.onerror` is never
written. This one seam is *called* as the modules are parsed rather than later, so the
mount now reaches `document-scope` on its own first and sets every field before a
single document module runs — which is also the safer order for the other five.
Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines
out, seven in, and nowhere else:
+ (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; }
- " createWebglAddon: createEngineWebglAddon"
+ " createWebglAddon: createEngineWebglAddon," and " installErrorReporter: installWindowErrorReporter"
- " window.onerror = function(msg, source, line, column, err) {"
+ " scope.installErrorReporter(function(msg, source, line, column, err) {"
- " };"
+ " });"
`terminal-webview-payload-hash.test.ts` takes the new length and digest.
Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror`
and a host that installs its reporter elsewhere leaving it null. The render check adds
a browser case: `window.onerror` is null before the mount, null after it, and null
after the component unmounts — with a real uncaught error thrown in between and
asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that
had simply stopped reporting, and a second error after dispose asserted to reach
nothing. Red with the mount's override removed: `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): empty the session closure's react-native-webview list
C7.6's census on main names the terminal as the last consumer and says whose work it
is: "The terminal is the third and is C7.5's, which drops the engine string and mounts
xterm in the document". This is that lane, so the list it left is now empty and the
session closure reaches `react-native-webview` from nothing at all.
Emptying a list weakens the case that reads it, because an empty result is also what a
scan that read no file reports, so two things change with it. The main case gains its
preconditions: the walk read a closure of more than 500 local modules, and it read the
three web siblings whose native halves are exactly the modules that would have
imported the package. And the control stops walking the list — with the list empty that
compared nothing against nothing — and walks the three native files instead, which do
import it, alongside the three web siblings, which do not.
`TerminalWebView.web.tsx` joins the answered list, so the case that the builder
resolves a web sibling rather than its native file now covers all three.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): pin the onerror seam against a handler the page actually owns
The case read `null` before the mount, while mounted and after dispose. That is true
but weak: a terminal that assigned `null` over a real handler would pass it, which is
exactly the takeover ruling 19 forbids.
So the page now installs a handler of its own in an init script, before the bundle
loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`,
compared inside the page because a function does not survive `evaluate` — at all three
points. Between them an uncaught error is thrown and both reporters are asserted to
see it: the page keeps the handler it installed, and the terminal's own listener still
works, so the readings cannot pass on a terminal that had simply stopped reporting.
After dispose a second error reaches the page's handler and not the terminal's, which
is what taking the listener off has to mean.
The `null` reading stays as its own case, because the other half matters too: on a page
that installed nothing the terminal must not leave a handler behind for the next
consumer to find.
Both go red with the mount's `installErrorReporter` override removed — `expected false
to be true` and `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): start the terminal document per mount (ruling 20)
Round 1's blocking finding: ES module bodies run once per page, so the page's
second mount re-imported nothing and inherited the first mount's elements,
listeners and error reporter. Measured after a remount: zero .xterm nodes in
the live DOM, no selection overlay, nothing reaching onEngineError, and
onWebReady still firing.
Ruling 20: no emitted module does work as it is parsed. Every top-level effect
moved into an exported per-module start function — 86 statements across 14
modules, plus three parse-time captures whose declarations became typed lets.
The generator emits one call sequence in module order at the foot of the
document, so the native script still runs them once at parse; the page runs the
same sequence per mount and dispose undoes the three that outlive the host
element (tap-dispatch, webgl-recovery, host-notify).
installErrorReporter now hands back its own undo, so it stays five seams at six
document sites rather than growing a sixth.
M2: a failed document chunk was an unhandled rejection with no engine error.
It now goes down the document's own reporting path, so the overlay names the
cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at
the wire in the render check.
L3: the seam count now reads five fields / six sites / three files everywhere.
L4: three unrelated web-overrides entries keep main's escaping.
Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256
2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the parse-time census tree without assertions
The changed-code gate refuses type assertions. The walker reached node fields
through `as Record<string, unknown>`; it now reads them with Object.entries,
which is checked and says the same thing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): move the document's state onto the scope (ruling 21)
Round 2's blocking finding, and ruling 20's second half: moving parse-time
effects out of the module bodies left the state behind. Nine module-level
bindings survived a mount, so the second terminal inherited a spent non-fatal
error budget (reporting nothing however it failed), the first terminal as its
committed surface (disposing it twice), and the first mount's momentum loop.
Every mutable binding now lives on the scope, and the scope carries one reset
the start sequence calls first: native once at parse, the page once per mount.
Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1,
host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1,
tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the
objects tap-dispatch and surface-touch-gestures used to own outright.
Because the reset is now the one initialiser, the start functions keep only
what it cannot do: element reads, listener installs and the reporter install.
Four start functions emptied and went; terminal-handle held nothing else and
is deleted from the order list. The scope type splits into state and host
seams, because a reset must restore the first and never the second.
Every stop function cancels what its module scheduled. Timers go back through
the handles the scope already held; frames go through the scope's own
scheduleDocumentFrame, so dispose can take back the ones no module tracks by
id. terminalGeneration and fitRetryToken carry forward across a reset, because
a stale callback tests itself against them and a reset to zero would make the
old number match again.
L2: the seams-before-scope case asserts the order in the emitted document, not
just non-membership. L3: the style docstring says what is true — one scope per
page, so mount refuses a second live document and gives the page back when a
mount fails.
Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256
6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse frames from a stopped document
The frame case went red under full-suite load: tearing the terminal down runs
the engine's own disposal, which calls back into these modules, and a frame
asked for on the way out was owed by nobody because the cancel had already run.
A stopped document now asks for no frames at all, so the ordering inside
dispose stops mattering.
The render case is also rewritten around the work that survives a loaded
machine. It gives the terminal a scrollback and sends one wheel, which reveals
the scroll indicator and arms the 550 ms timer to hide it again, and the
boundary between the two mounts is drawn when the first terminal leaves the
page rather than when the component is told to go — React unmounts on its own
schedule, and a callback that runs while the first terminal is still up is not
a leak. The precondition counts what the document scheduled under the first
mount, so an empty leak list cannot mean the wheel reached nothing.
Verified both ways at this head: red with stopViewportTransform and
cancelDocumentFrames removed, green with them, and green in the whole
config/scripts suite.
Payload 726081 -> 726195, sha256
67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): style only what the page mount owns
CodeRabbit on document-style.ts:16. The mount appended the document's whole
stylesheet to the page head, so its `*`, `html` and `body` rules restyled every
screen the shell can show and went on doing it after unmount. Ruling 19's
shape: the native document owns its page and keeps the sheet as it is; the page
mount may style only what it owns.
The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and
TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted
document does not move for the split - verified byte-identical before the seam
below. The page injects the element half only, with every selector held under
the host's own class, and xterm's sheet goes through the same rewrite. The
rewrite refuses an at-rule rather than passing its inner selectors through
unscoped.
A second leak of the same kind was in the same measurement: applyTerminalTheme
wrote the terminal background straight onto `html` and `body`. That is a sixth
seam - six fields at seven document sites now. Its default does exactly the two
writes it did; the page paints the host element instead. Emitted lines, old to
new: `paintWindowDocumentBackground` added beside the other defaults (3 lines);
`paintDocumentBackground: paintWindowDocumentBackground` added to the seam
factory (1 line); in applyTerminalTheme, the two `document...style.background`
writes become one `scope.paintDocumentBackground(background)`.
Leaving the sheet in the head after unmount is kept, and is now defensible: the
host drops the class on dispose, so every rule in it matches nothing until the
next mount.
The render check gains a case comparing `body` and `html` computed styles,
while mounted and after dispose, against a page of the same application with no
terminal on it, and asserting no rule of the injected sheet matches an element
outside the host. Verified red both ways at this head: unscoped sheet moves
`background-color` and `box-sizing`, and the inline theme write moves
`background-color`.
Payload 726195 -> 726363, sha256
9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the page mount's rules and frames their own oracles
Round 3 blocks on evidence, not on shipped behaviour. Each item:
H1. The scoping had no positive oracle: dropping the host class, or injecting
an empty xterm sheet, left the render check green, because every assertion was
about rules not escaping. The containment case now also reads four things off
the live elements under the host — xterm's own `position: relative`, the
viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width,
and the overlay's `position: fixed`. Red both ways: no host class reds all
four, an empty engine sheet reds the first.
H3. `cancelDocumentFrames` had no witness: the only leak the timer case could
see was the 550 ms hide timer, which its own module's stop cancels. There is
now a case whose witness is a frame taken through `scheduleDocumentFrame` —
the fit retry loop, with the surface hidden so the fit never commits and one
frame is always owed at dispose — and it reds when only `cancelDocumentFrames`
is removed. A unit covers the registry itself: a frame is held until it runs,
a cancel takes back every pending one and then refuses to schedule, and a reset
re-enables it.
The two scheduling cases now assert on their own witness kind, so neither can
stand in for the other, and the recorder judges a leak by whether the
`#terminal-container` that was on the page at schedule time is still in the
document — React unmounts on its own schedule, and a callback that runs while
the first terminal is still up is not a leak. The timer witness moved from the
scroll-indicator timer to the long-press timer, because the first needed a
drained scrollback and raced the engine under load; its precondition caught
that rather than passing.
L1. The two seam docstrings each sit on their own function.
L2. The parse-time census plants an element-read initialiser, which the
statement filter cannot see, and an inert object literal, which a reader that
flagged every initialiser would wrongly report.
L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that
never committed leaves two terminals and only one was reached. Deduplicated,
because they are the same object whenever no swap is open, and pinned both ways.
L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT.
Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render
check: 12 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the page document's dispose idempotent and owner-checked
CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A
handle outlives what it built - the component keeps one in a ref and React can
run a cleanup after a later mount has started - and everything dispose touches
is shared: the scope, the module sequences, window.__engineErrors. So a second
call, or a call from a handle whose document had already been replaced, tore
down the terminal that was on the screen and handed the page away while it was
still in use.
Each mount now carries a token, and dispose acts only when that token is still
the live one. A token rather than the host element or its class: two mounts can
be handed the same element, because the page remounts into a host React has
reused, so an element is not an identity and the class says only that some
document is using the host. The failed-mount path releases the page under the
same check.
Pinned both ways, red with the check removed: disposing twice leaves a terminal
put back after the first teardown alone, and a stale handle disposed after a
second document mounted changes nothing - the live markup stays, its terminal
is not disposed, and the page is still refused to a third mount.
Golden unchanged at 108,329 bytes; payload and hash unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let a pending page mount be disposed before its import lands
Round 4 on #21809.
H1. The mount claimed the page before its dynamic import and handed back a
promise, so a component cleanup that ran while the chunk was still in flight had
nothing to dispose: the claim outlived the mount it was made for, and Reload —
the recovery ruling 20 names — was refused as a second document. The claim, the
markup and the handle are now made synchronously, `ready` settles on its own,
and a mount disposed while its import was in flight releases without starting
anything. Pinned in the render check by holding the document chunk 20 s past the
15 s readiness watchdog, clicking Reload and waiting for the second mount to
become live; red at that wait before the change.
M1. The frame case's precondition asserted that a frame had been asked for while
the document owned the page, not that one was owed when it was disposed. The fit
retry commits on its first attempt whenever the grid still measures, so a dispose
between two refits owed nothing and agreed with an empty leak list for exactly
the reason under test — one run in five. The refit and the unmount now share one
discrete click, which React flushes before the event returns, and a mutation
observer reads the registry at the instant the host is emptied. Five red runs
without `cancelDocumentFrames`, all on the leak and none on the precondition,
and five green with it.
M2. Two mounts handed the same element, which is what the token is for: the
other six cases use a different element each, so a host comparison passes all of
them.
L1. A throw inside the start sequence released the token but ran no stop, leaving
the host-notify error listener installed until the next reset nulled its undo.
The sequence now unwinds the starts that completed, in reverse, before it
rethrows.
L2. A render case comparing the window and document listeners the page holds
with no terminal on it, before and after a mount, so a stop that forgets one is
a failure rather than a second copy per terminal ever shown.
L4. Separated the stacked docstrings in the parse-time-effects census.
The render check's bundle, server, browser and page helpers move to their own
fixture module: the cases are what is under review and the scratch route tree is
not, and the file was 16 code lines under its cap.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): count the page document's leaked frames from dispose, not from detach
CI's addendum to round 4's M1: the frame case failed with the fix present,
`expected [ Array(1) ] to deeply equal []`, on a slower runner.
What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every
other frame the document asks for — the document has no other rAF call site. It
is not an escape from the registry, so the registry is not what changes here.
Why it was counted: React unmounts in two steps. The mutation phase detaches the
host, and the passive cleanup that calls `dispose` runs after it — about 1 ms
later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner
shape this failed on. A frame served in that gap runs with a detached container
while the document is still live and has not been asked to stop, and nothing
could have taken it back: `cancelDocumentFrames` had not been called yet. The
oracle judged by the captured container's connectedness, so it read the gap as a
leak. It now counts only what runs after the last statement of `dispose`, which
is the class coming off the host, observed on the element because React may have
detached it already.
The same reading fixes the other direction. The precondition is read at that
same moment, and the witness is a refit re-armed from a frame of the test's own,
so the document is owed a frame at the end of every frame the browser serves and
a dispose cannot land where nothing is owed. The single refit the case used
before bought one frame, and the retry loop commits on its first attempt
whenever the grid still measures.
Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two
runs of three unthrottled, and in five of five with the CPU throttled 20x, where
the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with
`cancelDocumentFrames` removed, five red runs, all on the leak read and none on
the precondition.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop a page mount that lost its claim before it writes the scope
Round 5 on #21809.
F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await
import(...)` the whole body ran whatever had happened in the meantime: it
overwrote the six seams, called `startPageDocumentModules` and added the resize
listener, and only then did the caller's `.then` read the claim and throw the
result away. Everything after that await is shared — the seams are fields on a
module-singleton scope, and the start sequence resets that scope and installs
the document's listeners — so a mount disposed while its chunk was in flight was
writing over a mount that owns the page. The claim is now re-read the instant
the import lands, before any of it, and the build returns null.
`ready` for such a mount resolves rather than rejecting. Nothing failed: the
caller asked for the terminal and then asked for it to go away, and the chunk
arriving afterwards is not something for the error overlay to name. Before this
it rejected with a TypeError from `startSelectionMenuButtons` reaching for an
emptied host.
F2. The rejection handler called `release()` unconditionally, emptying a host the
mount may no longer own. It now releases only when the page is still its own.
Pins, both red first. In happy-dom: mount, dispose, then await ready — no
listener, timer or frame added while it resolves, the six seams unchanged,
`terminalGeneration` unmoved because the start sequence never ran, and the page
free for the next mount. Without the fix that case rejects with the
`startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight
case now reads the page's listeners with no terminal on it and compares them
against a page that mounted once and disposed once; without the fix the
abandoned mount leaves `window error` and `window resize` behind, because the
second mount's scope reset nulls the first mount's reporter undo.
The listener snapshot helper is shared with the mount-and-dispose case rather
than written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): give the render fixture's server and scratch tree back when it cannot start
CodeRabbit on the render fixture, plus its note on `release`.
The fixture. `chromium.launch` is the last step of the setup and the one that
fails in practice — no Chromium on the machine, an
`ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle
server is listening and the scratch tree is on disk. Rejecting there left the
caller without a handle, so `afterAll` had nothing to close and both stayed
allocated; the listening socket is the one that bites, because an open server
handle keeps the vitest worker alive after its last test has reported. The setup
after `mkdtemp` is now wrapped, gives back whatever it managed to take, and
rethrows the original error rather than anything the cleanup raised. The normal
close path awaits the server-close callback instead of firing it.
`release` in the page mount. The ownership check covered the claim but not the
two lines that make the terminal disappear, so a release that skipped the claim
would still empty the host and drop its class. The check now guards the whole
function, and round 5's caller-side check is gone as a duplicate of it: one rule,
inside the thing it governs. Both existing callers are unchanged in behaviour —
the synchronous planting catch always owns the page, and the rejection handler
was already guarded.
Pinned red first. The new case points the launch at an executable that is not
there, then asks the port the fixture actually served on for a connection and
reads the scratch directories in the temp dir. Without the rollback the port
still accepts and the scratch tree is still there; with it, neither. The port is
recorded by wrapping the real `createBundleServer` rather than standing a double
in front of it, and the case asserts a server was created at all, or the refusal
would mean nothing.
Two oracles were discarded on the way. `rejects.toThrow()` with no argument
passes for a build that broke for its own reason, so the rejection is matched by
message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not
`TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both
arms and agreed with everything; it also still lists the handle at the moment
the close callback runs.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): read the render fixture's rollback in a temp root of its own
Two defects in the case I committed in cb1833e675, both found by running it.
The anti-slop gate refuses module mocking, and it is right to: the case recorded
the served port by mocking the harness module around the real
`createBundleServer`. Gone, with no disable.
Its replacement read the shared temp directory for the fixture's scratch prefix,
which the render check next door writes to from a worker of its own. So the case
watched that tree appear and be swept up mid-run and called it a change: one red
in four alone, and red in the full suite, where the two run together. `TMPDIR`
now points at a directory this worker made, so the fixture's scratch tree lands
somewhere nothing else writes and what is left in there afterwards was left by
the setup under test. The failed launch also leaves Playwright artifacts and a
browser profile in there, which are Playwright's to clean, so the reading is
filtered to the name the fixture gives its own trees.
The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as
Node spells it, and read a tick after the close callback, because the handle is
still listed while that callback runs.
Both halves now fail on their own without the thing they measure: with no
rollback at all the socket count is one above its baseline, twice out of twice;
with the rollback but no `rm`, the scratch tree is still there. Three green runs
with both.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hand the started document to the mount in the turn that started it
Round 6's two LOW items, and the pins for the owner-checked release.
LOW 1. `started` was assigned in the `.then` after the build, a microtask later
than the start sequence and the resize listener it installs. A dispose in that
window found nothing started, skipped the teardown and released the page with the
document still running on it. The build now takes an `adopt` callback and calls it
as its last statement, inside the guarded region, so whoever has to undo the
start is holding it before that turn ends. Pinned by queuing the dispose behind
the document import the build awaits, which lands in exactly that window: without
the change the started document's resize listener survives the dispose, five red
runs out of five.
The owner-checked release, which landed in 8b37221b57 without a pin of its own.
The one path that reaches a mount's cleanup holding someone else's page is a
rejected import: everywhere else the build re-reads the claim after its await and
stops, but a rejection never gets that far. So the pin drives that — the chunk
fails for the first mount only, the mount is disposed while pending, a second one
is built into the same element as Reload does, and then the first rejection
arrives. Without the guard inside `release` it empties the live mount's host:
three red runs out of three, on the markup. It also disposes the abandoned handle
a second time afterwards and asserts nothing moves, which is LOW 2's missing pin
for round 5's F2.
That case is its own file because the import has to fail before the mount module
loads, and the mocking the failure needs is only permitted in `.test.ts` — the
anti-slop override does not cover `.test.mjs`, which is what refused the port
recording in the render fixture's case. It fails once, so the mount that replaces
it gets real modules and is a live document worth protecting; its own resize
listener is the witness that it started.
Two oracles were dropped. Vitest reports its own message when a mock factory
throws, not the one thrown, so which import failed is read from the factory's
counter instead. And a counter of successful factory calls read zero even though
the second mount got a working document, which measures vitest's caching rather
than this code; the live mount's listener replaced it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the listener wrappers the mount pins install
The mobile tests-typecheck ratchet was red on 63eb8a40ae: six TS7006 implicit
`any` parameters in each of the two mount pins, from arrow functions assigned
over `window.addEventListener` and `window.removeEventListener`. An overloaded
method gives an assigned arrow no contextual parameter types, so each wrapper's
`type`, `listener` and `options` were implicitly `any` under
`tsconfig.test.json`, which the product typecheck does not read.
Both wrappers now take their parameters from the bound original as
`Parameters<typeof realAdd>` and spread them through, so the signature is the
real one rather than three widened parameters. No casts and no `any`.
Re-verified that the change did not quietly disarm either pin, because a recorder
that counted nothing would also go green: with `release` unguarded the rejection
case still fails on the live mount's markup, and with the adopt deferred by a
microtask the single-mount case still fails on the started document's resize
listener surviving its dispose.
The ratchet itself is the finding worth keeping. It is not part of the mobile
`tsc` the rest of my gate set runs, and it had dropped out of that set when these
folds began, so three reports listed the other ratchets and not this one. It is
back in, and stays in.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart
Round 7's five items.
1. The queued-dispose case's precondition was vacuous. It read the host for a
missing container, which dispose empties on every path, so a build that returned
straight after its ownership check satisfied it. The wrapper now counts resize
adds and the case asserts exactly one, which is the document having started. Red
under that mutation, on the count.
2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that
also refused replaced the error the caller needs — the reason the setup failed.
The rollback is best-effort now and the original error is what comes back.
3. That cleanup stopped at the first throw, so a browser refusing to close took
the socket and the scratch tree with it, which is the leak the rollback exists to
prevent. Each of the three is asked independently and the first failure is
rethrown after all three have been tried.
4. The rejection case restores its `window` patch in a `finally`, as its sibling
does, so a failure part way through no longer leaves the patched functions behind
for everything that runs after it.
5. `dispose` left `started` set. `send` reads it, and what it holds names the
page's one set of document modules, so a stale handle could route a host command
into whichever document is live next. Nulled, and pinned: the stale handle pings,
and with the old code the *live* mount's `receive` answers `pong`, because the
scope's seam belongs to it by then. The precondition is the live handle's own ping
being answered, so the silence is the stale handle declining rather than the
command doing nothing.
Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup
itself, reachable only by making a browser or a socket refuse to close, and
standing something in front of Playwright to do it is what the anti-slop gate
refuses in this file's suffix. The rollback's own pin still covers the path that
matters, and both changes are read by it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): emit the terminal document as a factory
Ruling 22, commit 1 of C7.5b. The generator's concatenation already gave the 38
modules one function scope with one local `scope`; naming that scope a function is
what makes it the shape both hosts run, and what will let the page have its own
state per mount instead of a module singleton with a reset between them.
`createTerminalDocument(host)` is emitted around the same module bodies, in the
same order, followed by the same start sequence. It then declares `stop`, which
calls every module's stop in reverse order and takes back the frames the document
is still owed, and returns `{ send: handleMsg, stop }`. The native document is
that function plus one call with no argument, which is what the WebView has always
run: no argument means every seam is the window read it already did.
`createTerminalDocumentScope` takes the host and spreads the hooks it names over
the window defaults, filtering undefined so absent and present-but-undefined mean
the same thing. The emitted scope declaration is the one line the host reaches, so
the generator rewrites it and refuses if the line it expects is not there — a
rename would otherwise leave every call on the defaults with nothing to say so.
The golden moves by the wrapper and that one line, and by nothing else. 108,329 to
108,831 bytes, the whole diff:
-(function() {
+function createTerminalDocument(host) {
- function createTerminalDocumentScope() {
- return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() };
+ function createTerminalDocumentScope(host = {}) {
+ const named = Object.fromEntries(Object.entries(host).filter(([, hook]) => hook !== void 0));
+ return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams(), ...named };
- const scope = createTerminalDocumentScope();
+ const scope = createTerminalDocumentScope(host);
-})();
+ function stop() {
+ stopSurfaceTouchGestures();
+ stopTapDispatch();
+ stopSelectionOverlay();
+ stopNormalBufferSmoothScroll();
+ stopHostNotify();
+ stopTerminalInit();
+ stopWebglRecovery();
+ stopFitScale();
+ stopViewportTransform();
+ cancelDocumentFrames();
+ }
+ return { send: handleMsg, stop: stop };
+}
+createTerminalDocument();
The byte golden and the payload hash are re-pinned once: 726,363 to 726,865 bytes,
sha256 9950f177 to c7bbcb0b.
Four test files sliced the document with their own copy of the IIFE bounds, which
ruling 17 allows moving. They now share one reader in the test-support module
beside the one that locates a single module, and that reader names the factory and
its call. Every assertion is unchanged. The module-order guard and the region
reader compare against the text the document carries rather than a raw emit, since
the scope module is the one the generator rewrites; both go through one exported
function so neither can describe the rewrite differently from the generator.
`TerminalDocumentHostSeams` and the new `TerminalDocumentHost` moved to
`document-host-seams.ts`, which owns the six functions they type. Types emit
nothing, so the golden is unchanged by the move; it keeps `document-scope.ts`
inside its 300-line cap with no disable and no bump.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): emit the page's terminal document factory beside the WebView's
Ruling 23, and the first half of C7.5b commit 2: the artifact the page will
import. The page cannot run the native script, because building a function from a
string needs `eval` and the page's policy refuses it, and it cannot run the
modules either, because they are one singleton while the whole point of the
factory is a scope per call. So one emitted body gets two wrappers.
`buildTerminalDocumentFactoryBody` is now the shared half: the modules in order,
the start sequence, the stop handle and the return. The native script wraps it in
the declaration and the trailing call, exactly as before. The new
`terminal-webview-document-factory.generated.ts` wraps the same lines in a
`@ts-nocheck` module whose only other content is the type import and the
annotated signature. One generator run writes both, so the page's factory cannot
be a build behind the WebView's.
`@ts-nocheck` covers this one generated file. Every line of its body is esbuild
output from a module that was type-checked at its source, with `declare global`
blocks and type re-exports already erased and constants already substituted; the
one line a caller reads is the signature, and the generator writes it with its
types. `TerminalDocument` joins `TerminalDocumentHost` in `document-host-seams.ts`
as the shape the factory returns.
The pin is byte equality. `document-factory-artifacts.test.ts` strips each
wrapper and holds the remaining text equal, so the byte golden pins the page's
artifact by construction rather than by a second golden; it also reads the file on
disk against what the generator would write now, since that file is gitignored and
built by postinstall, and it refuses a trailing call in the page's copy, which
would start a document as the module was imported.
The path joins `.gitignore` and the oxlint ignore list beside the engine artifact.
The consumer census gains the generated file by name: it is the document, and its
one import is the host contract its signature is written against.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): call the document instead of starting its modules
The page's half of ruling 23, and ruling 24. The mount plants the markup and calls
the factory; the handle is `send` and a `dispose` that stops it. The document is a
function, so the page holds an object per call and nothing else.
Deleted with the singleton it was written for: `page-document-modules.ts`, the
claim token and `liveDocument`, `release`, the owner-checked `dispose`, the
second-mount refusal, `resetTerminalDocumentScope`, the `adopt` callback, `ready`
and every pending-import path. All of it existed because two mounts shared one
module-level scope and because the handle had to come back before its import did.
A call is a document now, so a second mount cannot reach the first one's state and
a caller's cleanup cannot arrive before there is something to clean up. The
second-mount refusal is not replaced by a one-line guard: with a scope per call
there is no shared state left to refuse for, and a host element with two
documents planted in it is the caller's own doing, visible on the screen.
Ruling 24 splits `message-bridge` by what it is, which is what made the page able
to run this text at all. Two more seams, eight now: `installHostTransport`, whose
window default installs the `message` listeners on window and document and hands
back their removal, and `hasEngine`, whose default is the `window.Terminal` the
engine bundle installs. The page answers a transport that installs nothing,
because its transport is the handle, and an engine that is always there, because
the engine is the import above. So the page no longer takes the shell's frames or
reports a missing engine on every mount, and `stopMessageBridge` takes the
listeners off — the WebView never removed them, which ruling 21 asks for.
The refit the bridge happened to own moves to `fit-scale`, which is whose work it
is; both hosts start it, and the mount's hand-copied five calls are gone. The
engine's disposal moves into `stopTerminalInit` for the same reason: the mount
cannot reach the scope any more, and a stopped document's terminal is a WebGL
context nothing will read again.
The start sequence the generator emits is now inside the document's own undo: a
start that throws runs `stop` and rethrows, so neither host can be left holding a
listener from a build that failed. That replaces the deleted entry module's
unwind, and it covers every start rather than the four that had one.
Readiness arrives the same way on both hosts. The document posts `web-ready`
through `postToHost`, which the controller already handles, so the mount-side
`confirmWebReady` is gone. That flush is also the one caller that reaches `post`
before the effect has a handle, which is why the component's queue stays and now
says so.
The golden and the payload hash move, 102 diff lines: the two seam defaults and
their state fields, the reset gone, `startFitScale`, the disposal, the bridge over
its seams, and the start sequence inside its try.
Tests: the seam tests and the unwind test move to the factory and the derived
start sequence; the frame registry builds a fresh scope instead of resetting one;
the two mount test files and the page entry's order test go with their subjects.
The render check keeps every behavioural case and loses two whose subject the
static import removed — a Reload while the chunk is in flight, and a chunk that
will not load, which is now the route's chunk rather than the document's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop censusing state a call of the document already isolates
Ruling 22 answers what ruling 21's state half was for. A module's top level is
emitted inside the factory, so a `let` there is one binding per call — which is
exactly what moving it onto the scope was achieving. The census that refused it,
and the planted-module precondition beside it, go.
The effect half stays, and the distinction is what a stop can reach. An effect in a
module body runs at the position its module is emitted rather than in the start
sequence, so no stop function undoes it and each call leaks another one. A binding
leaks nothing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop the derived start sequence warning on every suite run
The helper reaches its neighbours through a variable specifier, which the bundler
answers by rewriting as a glob — and it refuses to glob the directory the import is
written in, so every suite that loads this file printed the refusal twice.
`@vite-ignore` leaves the specifier alone and the module runner resolves it, which
is what was already happening. An extension does not help: with one the refusal
becomes the own-directory rule, and the path alias is not resolved for a runtime
specifier at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): separate the two commits inside the closure reading
The factory arriving is not the whole -1,890. Making the document a factory put the
`host` argument on `createTerminalDocumentScope`, which is this lane's only edit to
a module the closure already carried, and that alone is +80. Both numbers are in
the note now, so neither commit's cost is read as the other's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): read a document's elements from the host it was planted in
The last thing two documents on one page shared. Ruling 22 gave each call its own
scope, but the element reads were `document.getElementById` and the ids are in the
markup every host plants, so the second document's start sequence took the first
host's surface, overlay, handles and menu — two documents driving one terminal,
with the second host left empty.
Reachable, not theoretical: expo-router keeps the outgoing screen mounted for the
length of a stack transition, so two routes that both hold a terminal have two live
documents on the page while the animation runs.
`root` joins the host argument and `elementInRoot` is the one reader; the ten reads
in runtime-constants, surface-swap, selection-state-and-eviction and text-scaling go
through it. No id is renamed and nothing is refused: two documents on one page are
two terminals.
Two deviations from the ruling, both about *when* the default is read. `root` is
`ParentNode | null` with null meaning "the page I am in", rather than defaulting to
`document`: a data default is evaluated whenever a scope is built, which put a DOM
read into every slice evaluation and took eight keyboard-avoidance cases down with a
`ReferenceError` in their `vm` context. Null defers it to the read, which is the rule
the eight seams above it already follow. And the reader lives in
`document-host-seams.ts`, which declares the type, taking the root as an argument:
in `document-scope.ts` it was four lines over the file's 300 (no bump, no disable).
Red first, and the red was the second document: with a page-wide read the second
engine opens on an element outside its own host. `document-host-root.test.ts` plants
two hosts, starts a document in each, and reads which surface each engine was opened
on through the `createTerminal` seam, because the scope is not reachable from
outside. Falsified again after the fix by pointing the emitted reader back at
`document`: red, one case.
Two neighbours checked while here. The document-level touch listeners are already
host-scoped, because every handler tests its target against the scope's own surface,
overlay and handles, which are now this document's. `window.__engineErrors` is the
one page global left, and it is now kept rather than replaced per mount: a capped
diagnostic buffer, where a second mount was costing the first its captured lines.
Golden 27 diff lines, hash and length repinned: the reader, the `root: null`
default, and the ten reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the two-host engine double as the shape the seam returns
The double was reaching `createTerminal`'s return type through
`as unknown as Parameters<typeof queueMicrotask>[0] & never`, which the type-aware
gate reads correctly as an intersection with `never` and which was a cast standing in
for naming the type.
`TerminalDocumentTerminal` names it. Every member the type declares is present — the
ones `init` and the start sequence reach do something, the rest answer in the shape
their caller reads — and the shape needed no narrowing to accept a double. Two things
the type does not declare moved off it: where `open` was called is handed back beside
the terminal rather than exposed as a second getter, so the literal carries nothing
excess, and the buffer gained the `getLine` the type requires.
No cast, so nothing to write a SAFETY line about. Still red without the fix, checked
again after the retype by pointing the emitted reader back at `document`: one case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): filter a document's page-wide touch listeners to its own host (OTA phase C, C7.5b round 1)
Round 1 H1. The dispatcher's four listeners are on `document`, so with two
documents on one page (legitimate since `712daa80e6`) each is handed the other's
touches, and the two-finger branch acts before any target filtering: a pinch in
host B posted `mobile-clip-cancel-by-pinch` from document A and dropped A's
selection. Fixed at the source, one predicate beside `elementInRoot`, asked once
at the top of each handler rather than inside a branch. `root === null` is the
WebView, where the document is the page, so it answers yes to everything and the
native document is unchanged.
`e.target` is the element the finger went down on for the life of the touch, so a
select-drag travelling outside the host still answers yes on move, end and cancel.
Census of every global listener install under `src/terminal/document/` (non-test):
- `tap-dispatch.ts:241-244`, four capture-phase `document` touch listeners
(touchstart, touchmove, touchend, touchcancel): MUST be root-filtered; this fix.
- `document-host-seams.ts:165-166`, `window`+`document` `message` in
`installWindowHostTransport`: WebView-only. It is that host's transport seam
default and the page installs nothing (ruling 24), so no page carries two.
- `fit-scale.ts:163`, `window` `resize`: page-wide by nature. A viewport change
concerns every document on the page and the event has no target in either host;
both must refit.
- `webgl-recovery.ts:104`, `document` `visibilitychange`: page-wide by nature.
Backgrounding concerns every document on the page; its target is the document.
- No document-level mouse, wheel, keyboard or selection listener exists: those
are all on `targetSurface` or the menu buttons, read through `elementInRoot`,
so they are already inside their own host.
Red-first, the reviewer's own repro in `document-host-root.test.ts`: A and B both
in select mode, a two-finger touchstart in B's surface. Before: 2 failed
(A posted the pinch cancel too, and the control in A's own host cancelled B).
After: 3 passed. The control keeps the assertion honest — the same touch inside
the document's own host still cancels its selection.
Golden and payload hash move (regen is a review event): six hunks, +22/-1.
`eventTargetInRoot` emitted after `elementInRoot`; `touchIsThisDocuments` after
the CAPTURE constants; the three-line guard at the top of each of the four
handlers; `onDocumentTouchCancel()` becomes `onDocumentTouchCancel(e)`.
Document 728,119 -> 728,589 bytes, sha256 5b65315b... -> 1556f532...
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): unit-test the page mount's three paths no happy path reaches (OTA phase C, C7.5b round 1)
Round 1 M2. `terminal-web-document-mount.ts` had no unit test: the only reading
of it was the render check, which drives the whole page bundle in a browser —
right for behaviour, too coarse for three lines that only a failure reaches.
The body's "five test files whose subjects no longer exist" is wrong for three of
them. What ruling 22 deleted was the machinery (the claim token, `liveDocument`,
the owner-checked dispose, the second-mount refusal); these three subjects
survived it and lost their only cover:
- both engines disposed when a swap never committed (`terminal-init.ts:203-213`);
- the host given back when a start throws (`startDocumentOrGiveTheHostBack`);
- the component naming that throw's cause (`TerminalWebView.web.tsx:83`).
`terminal-web-document-mount.test.ts` (happy-dom) covers all three against the
real generated factory. Only the factory's *arrival* is mocked, delegating to the
real `createTerminalDocument` except for the one case that makes a start throw, so
no stub stands in for the program under test.
Six cases, each red against a deliberately broken line:
- two distinct terminals both disposed. Broken `new Set([scope.term,
scope.committedTerm])` -> `new Set([scope.term])`: expected [1,1], got [0,1].
- the same terminal disposed once. Broken the set -> a plain array: expected 1,
got 2. The pair is the dedup's own oracle; either half alone passes for the
wrong reason.
- the host emptied and the class dropped on a throw. Broken by deleting the two
lines in the mount's catch: host still carried `#terminal-container`.
- control: a live document keeps the markup and the class, so the two assertions
above cannot pass for a mount that planted nothing.
- `onEngineError` gets `terminal document failed to start - engine missing`.
Broken by deleting the component's `receive` in its catch: expected one
message, got none.
- control: nothing reported when the document starts.
The engine double moves to `document-terminal-double.test-support.ts` and both
readers of the seam share it; a second hand-written copy of thirty members would
drift as the shape grows. It now counts disposals beside reporting `open`.
Terminal suite 67 files / 625 tests -> 68 / 631. No product line changed, so the
golden and the payload hash do not move.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin both generated wrappers, and say what a shadow root would break now (OTA phase C, C7.5b round 1)
Round 1 M1 and L2.
M1, the mount's stylesheet comment was one version behind: it said the document
reads its elements with `document.getElementById`, which `712daa80e6` replaced
with `elementInRoot`, and drew its shadow-root conclusion from that read. Both
halves re-derived rather than reworded. A shadow root no longer breaks the reads
(`elementInRoot` is a `querySelector` under the host, which a shadow root
answers); it breaks this sheet, because a rule in the document's head does not
cross a shadow boundary, so it would have to move inside each root and be parsed
once per host instead of once per page.
L2, `document-factory-artifacts.test.ts` anchored the page body at `):
TerminalDocument {` and nothing else, so the header, the `@ts-nocheck` line, the
`import type` and the parameter's own line could all drift with the test green —
and that signature is the one line a caller of the page's artifact reads. Both
wrappers are now literal lines: nine for the page (header, directive, import,
blank, the three-line signature) and one plus two for the native script
(declaration, closing brace, trailing call). Literal rather than the generator's
own constants, which would only agree with whatever it emits.
Red controls, each with the generator changed and then restored:
- the page's `import type` reordered to `{ TerminalDocumentHost, TerminalDocument }`:
2 failed ("the page module opens with its wrapper", and the on-disk reading).
- the native trailing call changed to `createTerminalDocument({});`: 1 failed
("the native script closes with its wrapper"). The old anchor caught neither.
No emitted line moved: golden and payload hash unchanged, terminal suite 68 files
/ 631 tests.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): count only the fingers inside this document's own host (OTA phase C, C7.5b round 2)
Round 2's residual of `9824145e1f`'s class, one level in. `eventTargetInRoot`
settles whose event it is; every branch then counts `e.touches`, which is every
finger on the screen. A finger resting in host A is therefore B's second finger:
a one-finger touch in B's own surface reads `length === 2`, latches a pinch and
drops B's selection, and on touchend `length === 0` is never true so B's surface
tap never fires.
`touchesInRoot(root, touches)` beside `eventTargetInRoot` returns this document's
own fingers, and every count and index reads through it. A list rather than a
count, because `touches[0]` and `touches[1]` are page-wide in exactly the same
way as `touches.length` — the first finger on the screen may be the other
terminal's. `root === null` is the WebView, whose fingers are all its own: the
list is returned untouched, so nothing is allocated on a path that runs at frame
rate.
Census of every `touches` / `changedTouches` / `targetTouches` read under
`src/terminal/document/` (non-test). There are no `changedTouches` or
`targetTouches` reads at all; every read is `e.touches`:
- `tap-dispatch.ts`, 15 reads across the three handlers that take an event
(`[0]`, `[1]`, `.length`, and the list handed to `touchById`): MUST be filtered.
The document listens on `document`, so the event and its list are both page-wide.
- `surface-touch-gestures.ts`, 18 reads across its touchstart, touchmove and
touchend handlers: MUST be filtered. These listeners are on the document's own
surface, so the event is always this document's — but the list inside it is
still every finger on the screen, which is the whole defect.
- `tap-dispatch.ts:21-24`, `touchById(touches, id)`: no filtering of its own. It
reads whatever list it is given, and all three callers now hand it a filtered
one; its parameter widens from `TouchList` to `ArrayLike<Touch>`.
Red-first in `document-host-root.test.ts`, the reviewer's two repros, with the
three product files at `aba99c3e4f` and the artifacts rebuilt: 2 failed / 3
passed (pinch cancel posted with one finger on B's overlay; `terminal-tap` never
posted). With the fix: 5 passed. The pinch-inside-own-host control stays, and the
first repro lands on B's menu pill rather than its surface, because a single
finger on the surface dismisses a selection by design — on the pill, keeping the
selection is the whole assertion.
`document-host-seams.ts` also rewritten in the present tense where it read as
history.
Golden re-pinned: 19 hunks, +51/-33. `touchesInRoot` emitted after
`eventTargetInRoot`; one `const touches = touchesInRoot(scope.root, e.touches)`
at the top of each of the six touch handlers, and every `e.touches` read inside
them now reads `touches`. Document 728,589 -> 729,152 bytes, sha256 1556f532...
-> 02633389...
Correction to `9824145e1f`'s message: it cites `tap-dispatch.ts:241-244` for the
four installs, which at that commit are `261-264` (the line numbers are the
pre-fold ones from the review).
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): name the haptics module inside the merged session-closure reading (OTA phase C, C7.5b)
The merge's re-measured 4284 sat one above this branch's -40 added to PR B's +3,
and the comment could only say main had drifted "a module of its own". It is
`src/mobile-web-shell/bridge/bridge-haptics-notify.ts`, which C7.10 item E put on
the session route after PR B recorded 4323 — so pristine main reads 4324 against
the 4323 it holds, which is what #21908 re-pins.
Named here as #21908 names it on main. Nothing measured changes: 4284 is the same
number, and the module is in it by main's route rather than by anything this branch
did. `haptics.web.ts` was already in the closure; the bridge module joins it.
Verified by reading the closure's own module list rather than inferred from the
count: both haptics modules are in `local`, with the artifact-level totals
unchanged at 4284 / 934.
Which is why the reading is re-measured and not summed. A merged number arrived at
as -40 plus +3 would have read 4283 and been wrong about a module neither side of
the merge touched.
After #21908 lands, a further merge of main reconciles the two comments.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb