Commit Graph
11425 Commits
Author SHA1 Message Date
Jinwoo Hong 4f839cc8c9 chore(relay): cut the cell LB connection drain to 60 s and allow ten-cell same-cap batches (#21848)
* perf(relay): cut the cell LB drain to 60s and widen the same-cap batch to ten cells

Two independent sources of relay roll wall clock, neither of which protects a
host:

1. `connection_draining_timeout_sec` on the per-cell backend services was 300s.
   The same-cap job drains every host off the cell to a restart-safe condition
   before Terraform runs, so the LB drain only ever covers a host still
   mid-handshake. Measured 2026-09-16 over ten same-cap cell jobs, it sat as
   ~5m55s of dead time between `Apply complete` and the old VM powering off,
   inside an 8.5-minute `wait-until --stable` step. Now 60s, and pinned in the
   topology `check` block beside the other fixed-one invariants.

2. The same-cap wave capped a batch at four cells, so a 22-cell roll needed six
   batches, six single-use monitor gates, and a human handoff per batch. The
   wave workflow now declares cell_1..cell_10 with the identical serial shape
   and chaining, and the validator accepts two to ten.

The shared wave-index rule (`relay-monitor-evidence.mjs` and the relay-ops
preflight CLI) widens from 0-3 to 0-9 so the later cells can present the same
evidence; each job workflow keeps its own narrower range, so the capacity wave
stays at four. Cells remain strictly serial, one at a time behind the rollout
lease, each with its own live preflight.

Claude-Session: https://claude.ai/session/relay-roll-drain-timeout-and-batch-cap

* fix(relay): align the Asia topology plan validator with the 60s cell drain

`validate-relay-asia-topology-plan.mjs` rejected any Asia backend whose
`connection_draining_timeout_sec` was not 300, and
`cloud-deploy-relay-asia-topology.yml` targets
`google_compute_backend_service.relay_gce_cell["<cell>"]` per cell. With the
Terraform local at 60 that workflow would have failed its own plan review.

The validator's two restated topology values are now named exports, and a new
census test reads `relay-gce-cells.tf` and equates three statements of each:
the `relay_gce_topology` local, the topology `check` assert that pins it, and
the validator constant. Terraform cannot export a local to JS, so reading the
source is the only way to stop them drifting; the test was confirmed to fail
when the local alone is moved back to 300.

Repo-wide grep finds no other pin of the drain value.

Claude-Session: https://claude.ai/session/relay-roll-drain-timeout-and-batch-cap
2026-09-20 19:01:44 -04:00
Neil cb715898cd fix(release): pass the draft-verify tag on Windows pwsh (#21851)
The Windows matrix defaults to pwsh, so assert-github-release-is-draft.mjs
received an empty argv and failed with "tag is required" after the signed
installer was already uploaded. Force bash, interpolate the tag in YAML,
and fall back to env TAG.
2026-09-20 16:01:01 -07:00
Jinwoo Hong ec82173130 feat(mobile): mount the terminal document in the page over its own modules (OTA phase C, C7.5) (#21809)
* 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
2026-09-20 18:49:38 -04:00
Neil 1b9d218df5 fix(release): force draft publishes on tag checkouts (#21842)
Build jobs check out the release tag, so electron-builder still used
releaseType:release from older SHAs and published v1.4.206 as latest with
only Linux assets. Override publish.releaseType=draft on the CLI (workflow
YAML comes from main) and restore the draft helpers from the workflow ref.
2026-09-20 14:58:35 -07:00
Jinwoo Hong eb6068a434 fix(relay): stop a terminated checked-out PostgreSQL client from killing the cell (#21840)
pg-pool removes its own `error` listener when it hands a client out
(pg-pool@3.14.0 index.js:344) and only reattaches it in `_release`
(index.js:385). Between acquire and release the client therefore has no
`error` listener, so when Cloud SQL terminates that session mid-statement
the emit becomes an unhandled 'error' event and the process exits.
`absorbPostgresIdleClientErrors` cannot see it: pg-pool routes to
`pool.on('error')` only from the idle listener.

Attach a per-checkout `error` listener in the one seam every relay
checkout passes through, log a single warn line, and release the client
with the error so pg-pool destroys it instead of pooling a dead
connection. The listener is removed on release so it cannot accumulate.
The in-flight query still rejects, so existing failure reporting and the
transaction retry ladder are unchanged.

Claude-Session: https://claude.ai/session/ced32ebb-7155-4413-adad-1eccd14c2010
2026-09-20 17:46:25 -04:00
OrcaWinandm4air 4085e1cf60 fix(memory): release stale session registries (#21734)
* fix(memory): bound session and lifecycle registries

* fix(memory): bound transient filesystem registries

* fix(memory): cap path and locale caches

* fix(memory): bound runtime recovery registries

* fix(memory): bound host mirror gap verdicts

* fix(memory): bound shell startup env cache

* fix(memory): bound gitlab host context cache

* fix(memory): release removed ssh generations

* fix(memory): expire cloud refresh replay guards

* fix(memory): release retired plugin generations

* fix(memory): bound plugin log key retention

* fix(memory): bound automation authority generations

* fix(memory): bound native chat enrichment cache

* fix(memory): bound web session tracking generations

* fix(memory): bound codex credential absence paths

* fix(memory): bound WSL canonical path cache

* fix(memory): bound sparse checkout cache

* fix(memory): bound shared directory cache

* fix(memory): bound advertised URL scan snapshots

* fix(memory): bound automation manager cache

* fix(memory): bound web session reorder intents

* fix(memory): bound web session focus intents

* fix(memory): bound web session handoffs

* fix(memory): bound automation dispatch tokens

* fix(memory): bound host mirror waiters

* fix(memory): bound retained session activity

* fix(memory): bound retained session activity

* fix(memory): bound web session close intents

* fix(memory): bound cloud session cache

* fix(memory): bound WSL home cache

* fix(memory): bound SSH capability cache

* fix(memory): bound trust grant cooldowns

* fix(memory): bound WSL auth drain state

* fix(memory): bound Linear workspace credential cache

* fix(memory): bound local Git capability cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL preflight cache

* fix(memory): keep hot cache entries warm

* fix(memory): preserve generation fences across eviction

* fix(memory): close remaining eviction fences

* fix(memory): align evicted upstream generations

* fix(memory): trim successful capability probes

* fix(auth): retain expired refresh replay evidence

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-20 14:41:50 -07:00
Neil 72d61c459f fix(e2e): wait for terminal remount after golden worktree switch (#21837)
Mac release goldens failed after switching back to the original worktree:
sidebar aria-current landed while the store still pointed at the child tab,
so waitForActiveTerminalManager timed out. Wait for activeWorktreeId, force
the terminal tab visible, and restore this spec from the workflow ref so
older cut SHAs pick up the harness.
2026-09-20 14:28:03 -07:00
buf0-bot[bot]andbench 5b8b01b206 fix(terminal): arm native-paste suppression on middle-click in mouse-tracking TUIs (#21834)
* fix(terminal): arm native-paste suppression on middle-click in mouse-tracking TUIs (#21762)

Orca's own middle-click paste path bailed out entirely whenever the pane was
in mouse tracking mode (Claude Code, Codex, ...), skipping preventDefault()
and never arming the #8993 native-paste suppression window. Chromium's
native Linux middle-click paste then landed unsuppressed alongside the TUI's
own PRIMARY paste from the forwarded mouse report, pasting the selection
twice.

Split pane lookup from the tracking-mode gate: any terminal pane target now
arms suppression and blocks the native paste, while only the paste-to-PTY
(and the propagation stop that would swallow the click before xterm can
report it) stays gated on mouseTrackingMode === 'none'.

* fix(terminal): address review nits on the #21762 middle-click fix

- Fix a mis-attributing comment: the suppression window (not preventDefault,
  which only helps on mousedown while Chromium's native paste fires on
  mouseup) is what swallows the duplicate native paste.
- Drop the now-unused getPrimarySelectionMiddleClickPane.
- Assert stopPropagation is/isn't called per tracking mode in the repro test.

---------

Co-authored-by: bench <bench@example.invalid>
2026-09-20 14:23:30 -07:00
Neil 3cadcabe11 fix(release): keep GitHub releases draft until all assets exist (#21835)
electron-builder --publish always was creating a public GitHub release as
soon as the first platform uploaded, so /releases/latest could serve a
missing Windows exe. Keep the main-repo publisher on draft, pin draft
creation to the tag commit, re-draft immediately if anything flips public,
and refuse mac publish after the parent cut is cancelled.
2026-09-20 14:01:15 -07:00
Jinwoo Hong 68b11282a5 fix(relay): let the rehome evidence parser read a line the director grew (#21823)
The enable workflow reads the director's `[orca-relay] regional rehome
inventory` line out of Cloud Logging and pins the whole line with one regex.
Adding `hostNotArrivedLast24Hours` in #21813 made every healthy line stop
matching, so "Read fresh aggregate completion and abort evidence" threw
"no aggregate regional rehome inventory evidence" and the fail-closed step
disabled the durable switch at control generation 26.

The parser now requires the six original fields and tolerates further ones
in any order. Extra fields stay fenced by value shape rather than by pinning
the whole line: a field must be a bare name and a non-negative integer or
`none`, so `hostId=someone` is still not a counter and cannot ride along.
An absent count reads as null, not zero, because an older director not
reporting leaks is not the same as reporting none.

`hostNotArrivedLast24Hours` and `oldestActiveAgeMs` now reach the evidence
JSON and the operator step summary.

Two guards close the chain, each verified to fail on the regression it
exists for: a census in the relay package feeds the real formatter's output
to the real parser, and a script-side test pins the parser's output to the
fields the workflow summary renders.

Claude-Session: https://claude.ai/session/ced32ebb-7155-4413-adad-1eccd14c2010
2026-09-20 15:50:43 -04:00
Jinwoo Hong fa0010e8d6 fix(relay): abort rehomes whose host never arrived, without disabling the switch (#21813)
A regional rehome whose host went offline right after accepting the move
left its migration row open forever: the target had registered it, the host
held nothing on the source, and the completion sweep could never finish it.
Eight such rows filled REGIONAL_REHOME_CONCURRENT_LIMIT and every later
candidate came back deferred, silently, for 21 hours.

The only sweep that touched them fires at 24 hours and also sets
enabled = 0 on the durable control, so the first leak to age out would have
turned rehoming off, repeatedly.

Adds a director sweep that rolls such an attempt back to its source after
one migration lease, with abort_reason = 'host_not_arrived', reusing the
existing rollback (assignment epoch bump back to the source, lease removal,
superseded target reservation release) and leaving the switch untouched.
The 24-hour sweep keeps its disable as a last-resort latch.

The source cell now names why it deferred, on a new optional response field,
and the director stops walking its candidate page on a deferral no later
candidate can pass. Each poll that dispatched logs one summary line.

Claude-Session: https://claude.ai/session/ced32ebb-7155-4413-adad-1eccd14c2010
2026-09-20 15:13:27 -04:00
github-actions[bot] 0cc2b2688d Update README downloads badge 2026-09-20 18:28:31 +00:00
Jinwoo Hong 9fbdfc592c refactor(mobile): generate the terminal WebView document from typed modules (OTA phase C, C7.1) (#21804)
* 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

* 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

* 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(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
2026-09-20 12:33:03 -04:00
Jinwoo Hong d86d5cbbee fix(mobile): settle browser dialogs on the stream that reports them, map taps through the page scale, and sweep the frame budget on the real encoder (OTA phase C, C6.7) (#21799)
* fix(browser): settle a page's dialog on the stream that reported it (OTA phase C, C6.7)

Chromium hands `Page.javascriptDialogOpening` to one CDP session and takes the
answer only from that session; a client attaching afterwards is told `No dialog
is showing`, and every renderer-bound command it sends first blocks behind the
dialog it was sent to clear. Measured on Chromium 1217, 2026-09-20.

`browser.dialogAccept` and `browser.dialogDismiss` went to the agent-browser
bridge, which is always a later client, so the reply never reached
`Page.handleJavaScriptDialog`: the page stayed blocked and its next dialog
never opened. The screencast is the session that reported the dialog, so it is
the session that answers it. With no stream live on the page the bridge path is
unchanged.

No RPC shape changes.

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

* fix(mobile): keep the browser dialog card until the page takes the answer (OTA phase C, C6.7)

The card was cleared on the press, before the reply was sent. A page blocked on
a dialog is still blocked until the host settles it, so the pane reported an
answer the page never got and left the user looking at a stream nothing could
move. The host's `dialogClosed` is what says the page took it, and that already
clears the card.

The port-pair case runs the pane against a host that only moves the page when
the dialog is answered: alert, OK, the card stays while the reply is in flight,
then the confirm raises its own card and resolves with the button's value.

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

* fix(mobile): map a tap through the page scale the frame was painted at (OTA phase C, C6.7)

Mobile view emulates a phone viewport, and a page with no `<meta name="viewport">`
lays out at Chromium's 980 px default and is scaled into it. The frame metadata
says so: `deviceWidth` stays the emulated width and `pageScaleFactor` carries the
ratio. The browser's input commands take page CSS pixels, so a tap sent in the
frame's own device space landed at that fraction of the aim — 41% on the phone,
which is how the C6.6 proof found it.

The frame geometry now carries the scale the frame was painted at, and both the
tap map and the finger-sized click radius go through it. Measured on Chromium
1217, 2026-09-20: `scrollOffsetX/Y` must not be added — the frame is the visual
viewport and the commands take viewport-relative coordinates, so a click sent at
`device / scale + scrollOffset` landed a screenful past its target while
`device / scale` hit it. Web view mode reports a scale of one and is unchanged,
and so is a frame whose metadata carries no usable scale.

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

* test(mobile): sweep the frame budget on the encoder the product runs (OTA phase C, C6.7)

The C6.5 sweep encoded through `canvas.toDataURL` while the pane's frames come
from `Page.startScreencast`, so the certification and the product were measuring
two encoders. The sweep now drives the screencast, over the same 143 viewports
and the same noise, through the same real shell.

The two encoders agree to within a thousandth of a byte per pixel, and the
screencast is the cheaper of them: across the 111 viewports the budget fits it
measured 0.543986 to 0.552964 bytes per pixel, against 0.54470 to 0.55351 from
`toDataURL`. `WORST_CASE_JPEG_BYTES_PER_PIXEL` stays 0.56, now stated as the
screencast maximum plus 1.3%.

So the encoder is not what made the C6.6 device proof drop 1 frame in 41 at
402x593 with the budget on. That frame needs about 0.5649 bytes per pixel, above
everything either sweep has seen, and nothing here reproduces it. The docstring
records that rather than folding it into the constant.

The frame is emulated at one device pixel per CSS pixel and the page carries a
viewport meta: headless Chromium composites at the DIP surface size whatever
`deviceScaleFactor` says, so without both the canvas is scaled into the frame,
the noise averages away and the sweep reads about 0.12 bytes per pixel.

Also records what C6 ruling 1 costs, in the pane's docstring: "never dark" holds
only for a page that produces some frame that fits. With every frame over the
cap the pane sits on its busy spinner over an unpainted viewport, which is the
budget's reason for existing. No code change for that.

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

* test(mobile): wait on the double buffer's flip, not on a digest (OTA phase C, C6.7)

The render check waited for some painted layer to carry a digest other than the
previous frame's. `applyFrame` writes the next frame's URI onto the hidden layer
as soon as the frame lands and only flips the opacity once the decode resolves,
so that predicate is true before the frame is on screen.

Measured with a MutationObserver over the style writes, 2026-09-20: the URI
landed 80.7 ms after the emit and the flip at 85.7 ms, a 5 ms window in which
the wait returns and the visible layer is still the previous frame. The check
usually outran it by the round-trip it spends reading the layers back, which is
why it failed once in CI (#21790, d1d9a59288) with the second frame's digest
equal to the first's and no console errors.

Both cases that used that predicate now wait on the flip itself: the visible
layer must become the other one, which is one opacity write at
`settleBrowserFrameLayer` and the behaviour under test rather than a proxy for
it. Asserting the exact layer rather than any change keeps a pane with nothing
visible from reading as a flip. The digest assertions stay, as the content check
the wait no longer stands in for.

With `settleBrowserFrameLayer` stubbed to skip the flip the case fails on the
wait, so it is still an oracle for the behaviour and not only for the timing.

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

* fix(browser): one dialog reply shape, and no dialog outliving its stream (OTA phase C, C6.7 round 1)

Round 1 findings 2 and 4.

The reply body forked: `{}` when a pane was streaming the page and the
agent-browser payload when one was not, so `orca browser dialog accept --json`
printed a different body depending on whether anyone happened to be watching.
Both paths now answer `{ accepted: boolean }`. The bridge's own body is not
forwarded, because it is whichever JSON that agent-browser version prints, which
is the thing a caller cannot rely on. Pinned by asserting the two paths answer
the same value.

A replacement stream can meet an open dialog: the last subscriber leaving stops
the page's session, and a later subscribe builds a new one, which is what
backgrounding and foregrounding the phone does. Measured on Chromium 1217,
2026-09-20, with the dialog up: detaching the session that reported it does not
complete, `Page.enable` on a fresh session does not complete, and
`Page.handleJavaScriptDialog` on it answers `No dialog is showing`. So carrying
`dialogOpen` across would report a success the page never got, and the
replacement's own start would hang before it could try.

The stream therefore dismisses a dialog still open when it stops, before
`Page.stopScreencast`, so no session is ever replaced with one outstanding.
Dismissing is the conservative answer for every dialog type including
beforeunload, and the automation path has taken it since `cdp-debugger-events.ts`.

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

* fix(mobile): say when a dialog answer did not land, and drop the sweep's inert emulation claim (OTA phase C, C6.7 round 1)

Round 1 findings 1, 3 and 5.

The sweep's `deviceScaleFactor` is inert and the docstring claimed otherwise.
Headless Chromium composites at the DIP surface size whatever the factor says,
so the sweep returns the same 0.543986 / 0.552964 to six decimals at 1 and at 3;
`Emulation.setDeviceMetricsOverride` is there to size the surface and nothing
else. What actually guards the measurement is the document's viewport meta,
without which the page lays out at 980 px, the canvas is scaled into the frame
and the noise averages away. That is no longer left to a comment: a case now
measures the same frame with the meta removed and reads under 0.3 bytes per
pixel, which is what proves the sweep's 0.5 floor is the thing that would catch
it.

The card cleared only on `dialogClosed`, but the request still suppressed its
error and timed out at 5 s, so a refused or timed-out answer left the modal up
with nothing said and a button that looked dead. A failed answer now marks the
open card, which keeps its buttons live for the retry. The updater is what stops
a late failure reopening a card the page has since closed.

The "never dark" note moves onto the component it describes, from the detached
block it was sitting in between the imports and the props type.

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

* fix(mobile): scale the wheel, answer a dialog once, and capture the sweep after the paint (OTA phase C, C6.7 bots)

Three bot findings on 6341321f44.

The wheel converted screen deltas with the frame fit and the zoom and left the
page scale out, so a flick on a page with no viewport meta delivered about 41%
of the scroll asked for — the same omission the tap had, one call site over.
Both call sites now go through one scale, named for the three factors every
screen-space quantity this pane sends has to carry.

`dialogOpen` stayed true until `Page.javascriptDialogClosed`, so two answers in
flight both sent `Page.handleJavaScriptDialog`. Chromium takes one per dialog:
the second is refused, or settles the page's next dialog unseen if it has
already been raised, and the rejection came back out of the RPC. One settlement
promise per dialog now, handed to duplicate callers, cleared when the dialog
closes or when the command fails — and only by its own dialog's generation, so
a late failure cannot disarm the next dialog's answer. The stop path reuses a
settlement already on its way rather than adding a second command. On the pane
the card's buttons go dead while an answer is in flight, keyed on the token of
the answer that armed them, so a reply landing after the page moved on writes to
neither card.

The sweep painted its noise before `Page.startScreencast` and accepted whatever
frame the deadline left it, so a capture taken before the paint committed could
have been measured as the cost of the canvas. The noise is now painted after the
screencast is running, through the same CDP session and behind two animation
frames, and only frames that arrive after that commit are used; none at all is
an error rather than a fallback. Re-measured across all 111 budgeted viewports:
0.543986 / 0.552964, unchanged to six decimals, so the constant stays where it
is and the capture is guaranteed rather than lucky.

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

* test(mobile): pin that a late dialog failure marks its own card, and quote the real case title (OTA phase C, C6.7 round 2)

Round 2 delta findings a and b.

The mis-stamp itself is already closed by the per-dialog token the bot fold
added: the failure updater skips unless the open card still carries the token of
the answer that armed it, and a dialog raised after that carries none. What was
missing is the sequence that proves it. The case answers the alert, lets the
page settle it and raise the confirm without the pane hearing the reply, then
times that reply out: the confirm keeps its own card and its live buttons. With
the token check reduced to the null guard it was reviewed with, the confirm is
stamped with the alert's failure.

The sweep docstring quoted a case title that does not exist, which is a pointer
that reads as a citation and resolves to nothing. It now quotes the real one.

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

* refactor(mobile): give the browser pane the one dialog-state type (OTA phase C, C6.7 bots)

pullfrog: the pane declared its own `BrowserDialogState` without `error` or
`pending`, and only structural typing let the hooks' wider setter flow into it,
so the fields the card renders were undeclared on the pane's own state. The pane
now imports the exported type and the local declaration is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 11:33:12 -04:00
Jinwoo Hong bd5177801b feat(mobile): put the page's pickers, paste and editor fallbacks on the media verbs (OTA phase C, C7.6) (#21795)
* feat(mobile): put media picking behind a platform seam (OTA phase C, C7.6)

The session screen picks images three ways — the photo library, Files, and the
pasteboard — and all three are native modules a page cannot import: the codegen
lookup `expo-image-picker` and `expo-document-picker` run at import throws in a
browser, and the route manifest imports every route, so one of them in a page
closure is the whole bundle down rather than one picker.

`src/platform/media-picker.ts` is the phone's, delegating to the same three
calls the screen already made. `.web.ts` is the page's: `native.media.pick`,
then `read` in order to `eof`, then `release` for every handle it was handed,
including the ones its caller never took — the shell holds eight staged files
at a time and an abandoned pick otherwise waits out the five-minute TTL. A
refusal rejects with the shell's code on it and is never folded into the empty
answer that means the user cancelled.

The bytes are concatenated decoded and encoded once, because the wire promises
`eof` and nothing about the length: a shell answering a range shorter than the
one asked for ends a chunk on a partial base64 group, and a reader joining the
strings would fold that padding into the middle of the file.

The census walks the session route module's own closure — the route is not
registered until C7.7 — and names any module that reaches a picker or
`Clipboard.getImageAsync` directly. Today that is the two modules C7.6's next
commit moves, listed by name so the list goes empty rather than the rule going
quiet.

Inert: nothing calls the seam yet.

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

* feat(mobile): put the session's paste and attach on the media seam (OTA phase C, C7.6)

The terminal paste read the pasteboard through `expo-clipboard` directly and
the two attach paths called `pickMobileImage`/`pickMobileImages`, so the page's
closure carried `expo-image-picker` and `expo-document-picker` — native modules
whose import throws in a browser.

All three now go through the seam. Text is `native.clipboard.read` on the page,
which the clipboard seam gains a reader for: `expo-clipboard` resolves to
`navigator.clipboard` there, which needs a secure context the iOS shell's
custom scheme is not. An image is `pick { source: 'clipboard' }` rather than an
inline value, because a clipboard image is 24 MiB of base64 against an 8 MiB
reply ceiling.

The census over the session closure is empty now and asserts the seam is in it,
so a rule that found nothing is one that had something to find: with the three
call sites restored it names all three.

`mobile-image-source-picker.ts` stays the phone's implementation, reached only
through the seam's native sibling, and resolves out of the web closure entirely.

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

* feat(mobile): resize a clipboard image on the page with a canvas (OTA phase C, C7.6)

The paste hook carried the raster shrink inline over `expo-image-manipulator`
and two `expo-file-system` writes. Both are native: the manipulator has no
browser build, and the temp file exists only to work around an iOS loader that
cannot decode a large base64 data URI, which a browser does not need.

Split into `mobile-clipboard-image-resize.ts`, unchanged, and a `.web.ts` that
decodes one `<img>` from the data URL the shell's `img-src 'self' data:`
already admits, draws it into a canvas at the target size and reads the PNG
back out of `toDataURL`. It reports the canvas's own size rather than the size
asked for, because a browser clamps a canvas past its area limit and the
downscale loop above would otherwise retry a raster that never shrank; and it
awaits `decode()` rather than `onload`, which never fires for a source the
browser cannot read and would leave the paste waiting on a promise nothing
settles.

Measured in Chromium under the shipped header, on a noise PNG because that is
what PNG compresses least: 1400x1000 encodes to 5,476,032 base64 characters and
converges in one pass to 368x263 and 397,220, which is 75.8% of the upload
path's 512 KiB chunk. Zero policy violations and zero page errors. Red under
three mutations: the source returned unchanged, a reported size the canvas did
not draw, and `onload` in place of `decode()`.

`computeMobileClipboardImageDownscale` moves to a leaf for the reason the
upload-chunk constant has one: the check wants the arithmetic and not the
upload path's RPC operations behind it.

The page closure now carries none of `expo-image-picker`,
`expo-document-picker`, `expo-image-manipulator` or `expo-file-system`, pinned
beside the seam census.

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

* feat(mobile): give the two WebView editors their plain web fallbacks (OTA phase C, C7.6)

`MobileRichMarkdownEditor` and `MobileHtmlPreview` are the session closure's
other two `react-native-webview` consumers. On the web that package renders the
line "React Native WebView does not support this platform" where the surface
was, so nothing it was mounted for works and the closure pays for a module that
cannot do its job.

Ruling 8: each gets the plain state it already degrades to, and no second
renderer. The editor renders the Markdown source in one field on the text-input
seam, so the screen around it keeps the text, every edit through `onChange`,
and Save, Discard, Copy and Refresh; the degradation is the formatting toolbar,
whose fifteen commands are the rich document's. The preview renders its own
Source tab; the degradation is the rendered artifact, and the toggle goes with
it, because a control that can only be in one position is a control that lies.

Neither is smaller than a DOM renderer, which is why neither is one here. The
editor's toolbar would need a `contenteditable` implementation with its own
escaping, and the preview has no nested frame to sandbox agent-produced HTML in
at all — the shell's policy carries `frame-src 'none'` and `child-src 'none'`.

`dismissKeyboard` blurs the field rather than calling `Keyboard.dismiss`, which
is a stub on React Native Web; `onKeyboardInsetChange` is never called, because
it exists to correct for a WebView's covered area and on the page
`keyboard-occlusion.web.ts` is the only measurement there is.

The closure census names the one consumer left, `TerminalWebView.tsx`, which is
C7.5's: with both siblings removed it names all three.

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

* fix(mobile): read a destructured clipboard alias in the media census (OTA phase C, C7.6)

The census recognised `Clipboard.getImageAsync` as a property access and
nothing else, so `const { getImageAsync } = Clipboard` reached the same
function without ever writing one and the closure was approved. On the page
that call is `navigator.clipboard`, which needs a secure context the iOS
shell's custom scheme is not, so the approval was for a path that dies at the
browser clipboard API.

Aliases are now resolved to a fixpoint — `const pasteboard = Clipboard` makes
`pasteboard` the module too, and the chain has no length limit — and a
destructuring off any of them is reported at its declaration, which is the line
to delete. The destructured name is read the way the import clause's is, off
`propertyName` when the element renames it, so `{ getImageAsync: readImage }`
is the same offence spelled differently. A binding element's `name` can be a
nested pattern and a `propertyName` can be computed, so the text is taken only
off a node that has one.

Red-first with each shape planted in the scratch tree before the rule moved:
the plain destructuring, the renamed one and the re-destructured chain were all
missed. Dropping the fixpoint afterwards loses the chain; reading the local
name instead of the property loses the rename. `{ getStringAsync } = Clipboard`
stays unreported, because text off the pasteboard is the clipboard seam's and
not this rule's.

The session closure is still empty under the widened rule.

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

* fix(mobile): release every item a clipboard pick answered (OTA phase C, C7.6)

`readClipboardImage` destructured the first staged item and released only that
one, while `pickImage` already guards the same shape through `readPicked`.
`multiple: false` is what the page asks for and not what a shell promises, so a
caller taking the first of several would hold the rest against the
eight-handle cap until the five-minute TTL. Today's shell stages at most one on
the clipboard arm, so this is the seam's own docstring made true rather than a
leak in the field.

Red-first with two staged clipboard items: releasing only the one read leaves
`media-2` held, and the second is now returned without ever being read, which
is what the single-image pick does.

The refusal case is one path over both codes a pick can answer with: the
registry's `native_media_handle_cap`, raised before a picker runs, and ruling
6c's `native_media_too_large`, raised once a picked item has been weighed. A
code outside the seam's vocabulary floors to `native_verb_failed` rather than
crossing verbatim, which is what makes naming the exact code load-bearing.

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

* test(mobile): read the upload chunk from its module in the resize check (OTA phase C, C7.6)

The canvas resize check restated `512 * 1024` as the budget it holds a run to.
A check carrying its own copy of a product constant is one that goes on passing
after the upload path's chunk has moved, which is the reason the harness reads
the CSP, the protocol version and the window caps out of their own sources.

`readClipboardImageUploadChunkBase64Chars` joins them, evaluating the product
the way the window caps reader does. Proved live by moving the constant: at
64 MiB the run reds on the fixture no longer being over the budget, and it is
back to 512 KiB here.

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

* fix(mobile): read element access in the media census and correct two claims (OTA phase C, C7.6)

Round 2, four lows.

The census read `Clipboard.getImageAsync` and not `Clipboard['getImageAsync']`,
which is the same call, the spelling a bundler produces, and the one a reader
reaches for to get around a rule about dots. Element access with a string
literal is now read the same way; a computed key is not, because its value is
not in the source and guessing would report a line nobody can act on. The
closure test cannot back this up — `expo-clipboard` legitimately sits in the
session closure — so the scratch fixture is the whole of the evidence, and it
reds with the arm removed.

The fixture also could not tell the alias fixpoint from one source-order pass:
every planted chain happened to be declared in the order a single walk learns
it. `reverse-order-alias.ts` is declared back to front, and is valid at run
time because the destructure sits inside a function the module body finishes
before anything calls. Bounding the loop to one pass now reds it.

The canvas resize justified reading its size back off the element by a browser
clamping past its area limit. That is not what browsers do: the width attribute
reflects whatever it was assigned, so the returned size is always the target.
The real reason is narrower and is now what the comment and the override entry
say — the dimensions and the bytes come from one element, so a caller's
bookkeeping cannot describe a raster that was not encoded.

The override entry also carried a stray apostrophe in `img-src 'self' data:`.

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

* fix(mobile): say what the clipboard contract shipped as, and read a backticked key (OTA phase C, C7.6)

Round 3, three lows.

The merge took main's `clipboard.ts` byte for byte, so its reader docstring
still described the state C7.2 shipped: one verb on the web, and a page whose
lack of an image verb degraded into the old path. The design that shipped is
the other one — this seam owns the pasteboard on both platforms and the page's
`readImage` runs `native.media.pick { source: 'clipboard' }` with the chunked
read behind it. The prose now says that, and says that null still means an
empty pasteboard while every other outcome rejects.

The same merge left `clipboard` twice in the paste hook's dependency list, one
from each side. Deduped.

The census read a quoted element-access key and not a backticked one, so
``Clipboard[`getImageAsync`]`` escaped a rule that catches both other
spellings. A template with no substitution is a string literal with a different
quote, and reading only one of the two leaves the other as the way around.

The computed-key plant could not see the literal-kind check at all: its
variable was named `key`, so reading the identifier's text found nothing
either way. It is now named after the method and holds a different one, which
makes dropping the kind check a false positive on a call that reads text.

Red-first: the backticked access planted before the rule moved is missed;
ignoring template keys afterwards misses it again; accepting any key node
reports the computed plant.

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

* fix(mobile): hold canPickMedia to all three verbs and seed the census from import() (OTA phase C, C7.6)

Two bot findings.

`canPickMedia` answered true on `native.media.pick` and `native.media.read`
alone, but every image read releases what it picked. On a route without
`native.media.release` the release rejects, the cleanup swallows it by design,
and the staged file stays live to the five-minute TTL: eight pastes and the
next pick is refused at the handle cap, with nothing on screen to say why. A
route missing one verb has no working image path, so `contents()` now says so
up front rather than after four of them. Red-first: a route granted pick and
read but not release answered `image: true`.

The census seeded its aliases from static import and export declarations only,
so `const Clipboard = await import('expo-clipboard')` produced no offender —
while the bundler resolves a literal dynamic import into the closure exactly as
a static one. A dynamic import is now read wherever it appears: `await` and
parentheses unwrapped, the assigned identifier seeded as an alias, a
destructuring off one reported at its declaration, and a picker module reported
at the call, since reaching one at all is the offence. A specifier that is not
a literal is left alone, for the reason a computed key is.

Red-first with all three forms planted and the seeding removed: the namespace
alias, the destructuring and the picker import are each missed.

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

* fix(mobile): seed the media census from a backticked import() too (OTA phase C, C7.6 bots)

CodeRabbit: `import(`expo-image-picker`)` is as static to the bundler as the
quoted form, but the census read only a string literal specifier, so a
backticked one joined the closure unseen. A no-substitution template literal
now seeds it the same way; the planted fixture is reported at its line and
was unreported before the arm.

pullfrog: the clipboard seam's docstring counted the web read as two verbs
where its web sibling counts one for text and three for an image. It now
counts the same way in both files.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 11:24:53 -04:00
Jinwoo Hong d5dc7b9cf8 feat(mobile): budget the terminal snapshot on serialized bytes and hold live output instead of ending the stream (OTA phase C, C7.3) (#21785)
* fix(mobile): budget the mobile terminal snapshot on the bytes it serializes to (OTA phase C, C7.3, ruling 1)

The desktop trims a mobile snapshot to 512 KiB of raw terminal text. A client
reading it through the page bridge measures the serialized event against a
640 KiB frame cap, and an ANSI snapshot is mostly ESC bytes, each of which JSON
spends six on. Measured here on a colour-dense 80-column screen: the raw budget
hands back 465,766 bytes that serialize to 669,268 — 102.1% of the cap — so
`deliver` answers `cancel(id, 'overflow')` and the terminal is dead before its
first live byte, with no recovery that does not reproduce it.

`terminal.subscribe` gains an optional `snapshotByteBudget`. A subscriber that
sends one is trimmed against the JSON its payload will really cost: the escaped
text, plus the metadata it cannot bound from its own side — a path, the OSC-link
list, the pending escape tail. A subscriber that sends none, which is every
socket client and every older page, keeps the raw byte rule exactly.

No negotiation, and none is needed: the field is additive and optional, so an
older desktop ignores it and trims as it always did. The page then still has a
snapshot over its cap, the shell still ends the stream with `overflow` (C0.3
stands), and the terminal renders its stream-error state rather than a blank
pane. The page derives the number from the cap less the event envelope rather
than writing it down, so a cap that moves takes the budget with it.

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

* feat(mobile): hold and coalesce terminal output instead of ending the stream on the window (OTA phase C, C7.3, ruling 2)

The shell's backpressure window ends a stream when the page falls 4 MiB behind.
That is right for a stream whose reader can survive a gap and wrong for a
terminal, whose reader cannot see the hole a dropped chunk leaves — and the
window does not wait for a page to go wrong. Measured by the design: the host
produces 70.3 MiB/s of JSON and real xterm applies 2.2 MiB/s, so an ordinary
`cat` crosses the window in 62 ms. Replayed here through the real ledger against
a page draining at that rate, a 5 MB transcript ends the stream after 85 of 107
chunks plain and after 40 of 107 under `grep --color`.

Keyed by method on the shell, since the page cannot pick its own window,
`terminal.subscribe` now holds what it cannot send, merges consecutive output in
escaped bytes under the frame cap, and delivers as the page acks. Nothing is
dropped: merging concatenates, and the only exit that loses bytes is ending the
stream, which the page is told about. Both transcripts now arrive whole and in
order, in 104 and 81 frames, with the largest frame at 622,551 bytes against the
655,360-byte cap.

It ends only on the two things that are not slowness: a page that has acked
nothing for 20 s, an order of magnitude above the 1.9 s a full window takes to
drain, and a backlog past 32 MiB, which at that drain is about 15 s of catching
up. Both reach the page as `overflow`, because the shell is the installed app
and its page comes from the desktop, so a reason the page's reader has never
heard of is a frame it drops rather than an end it acts on. Which one fired, the
coalesced-frame count and the peak pending bytes go to the diagnostic log, which
is the device proof's only oracle for any of this.

Every other stream keeps the byte window exactly, and an event over the frame cap
still ends any stream, terminal or not (C0.3). The landed window cases now name a
stream the window still governs, so the two rules are never read off each other.

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

* fix(mobile): narrow the event arm the backlog replay reads

A binary event carries no `payload`, so the tests-typecheck ratchet refused the
reach into it.

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

* refactor(mobile): narrow the snapshot serializer to the buffer source it reads

The changed-code casting gate refused the test's stub runtime, and it was right
to: a service-wide type for a function that calls one method is what made the
stub need an assertion. The parameter now says what it needs, and the fixture
path is no longer one a machine-path grep reads as a leaked local checkout.

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

* fix(mobile): measure the snapshot budget by building the payload, not by summing fields (OTA phase C, C7.3, ruling 14)

Round one summed the escaped text and four metadata fields. The payload a
bridged client assembles carries nine more — `kind`, `cols`, `rows`,
`requestId`, `displayMode`, `reason`, `seq` and both truncation flags — plus the
`type` and `streamId` it adds, the `serialized` key and the object's own braces.
So a snapshot this host accepted at exactly the budget, with
`truncatedByByteBudget` false because nothing had trimmed it, published over the
cap and the stream ended with `overflow` before a byte was painted.

Measured here on a screen sized to land exactly on round one's budget: the
published payload is 655,446 bytes against a 655,273-byte budget, 173 over, and
the frame it makes is over the 640 KiB cap by the same amount.

The metadata is now built by one function that `sendSnapshotFrames` and the
budget both call, and the budget stringifies the payload that function produces.
Nothing is summed and nothing is estimated, so a field added to the frame is paid
for by the budget the moment it is sent. Where a value is not yet known — the
truncation flags, and `seq` or `requestId` at a site that has not fixed them —
it is measured at the widest `JSON.stringify` can write it, which is a bound
rather than a guess, and forcing `seq` to a number also opens the three fields it
gates so those are counted too.

The budget therefore travels with the publication fields, because the payload
cannot be built without them.

On the page, the event envelope is now derived in one place in the protocol
module and read by both the snapshot budget and the shell's own merge budget, so
the two cannot drift; the page pins the number it sends and the host's cases name
that pin, since the two programs cannot import from each other.

The case that re-implemented the host's measure is gone: it could not have seen
this, because it was the same arithmetic twice.

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

* fix(mobile): arm a held terminal's silence clock only while something is pending (OTA phase C, C7.3)

The invariant is "armed implies waiting on the page", and round one broke it in
the one direction that kills: an ack re-armed the clock and the drain that
followed emptied the queue without clearing it. A terminal that had delivered
every byte and gone quiet — which is what a terminal does between commands —
would die on `overflow` twenty seconds later.

The clock is now synchronised after every change to the queue, so it is armed
exactly while something is held. A rule that only ever arms is a rule that only
ever ends more streams.

Red-first: with round one's arming, an idle stream whose queue has drained still
reports its clock armed, and firing it ends a healthy terminal.

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

* test(mobile): pin the held-stream cases the rulings name (OTA phase C, C7.3)

Six cases nothing covered. Two subscriptions on one shell keep separate backlogs,
so a busy terminal cannot end a quiet one. A stream the page unsubscribed mid-
backlog posts nothing after, and neither does one that has already ended, however
much was still held. A payload that is not output breaks a merge run and keeps
its place, because a resize is state the reader applies in order. And the budget
boundary is checked on the side that enforces it: a payload at exactly the number
the page asks the desktop for is delivered inside the cap, and one the cap cannot
hold ends the stream under C0.3.

The replay no longer acks unconditionally in its catch-up loop. That was the page
behaving better than a page can — it acks on reading frames — and it is what hid
the silence clock left armed over an empty queue. The held-stream cases close the
window on its frame count rather than on four megabytes of string work.

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

* refactor(mobile): give the event-envelope derivation its own module (OTA phase C, C7.3)

`bridge-envelope.ts` is at its line cap and is the protocol's schemas; what a
frame costs around its payload is a derivation over them, and two budgets read
it — the snapshot the page asks the desktop for, and the output the shell merges.
One module, so they cannot drift and neither file is pushed over its limit.

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

* test: narrow the budget fixtures instead of asserting them

The changed-code casting gate refused six `as NonNullable<...>` in the new
budget cases, and it was right to: a fixture that serialized nothing is a broken
case rather than a null to assert away.

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

* refactor: give the snapshot payload shape its own module (OTA phase C, C7.3)

`terminal-snapshot-publication.ts` crossed the root config's 300-line cap, which
mobile's own lint does not apply and CI does. The frame's shape and what it costs
a client reading it as one payload is a description the budget and the sender
both need, so it is the part that leaves.

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

* fix: empty a snapshot the budget cannot fit instead of posting it over (OTA phase C, C7.3, ruling 15)

Both trimming loops published the zero-row candidate whatever it measured, and
zero scrollback is not a small screen: a wide colour-dense viewport still carries
its 24 live rows. A capped subscriber could get one frame over its cap, end the
stream on `overflow` and paint nothing — worse than a blank terminal, because a
blank one repaints on the next byte of output and a stream that never opened does
not reopen.

Ruling 15: a budgeted subscriber gets that frame with its text emptied and
`truncatedByByteBudget` true, never over and never refused. The raw rule keeps its
fallback, so an older page and every socket client are served exactly what they
were before. Below the metadata the frame must carry there is nothing left to give
up, and that boundary is pinned rather than claimed away.

The renderer loop is the same walk reached by a different caller and had no test
at all; its runtime parameter is narrowed to the two methods it reads so a case
can stub it without a cast.

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

* fix(mobile): report what a held terminal stream did instead of calling it an outlived view

The backlog report had no branch in the reporter, so it fell through to the "a
view outlived its host" warn and every field it exists to carry was discarded.
The key made it worse: keyed by kind alone, one backlog per host was ever logged,
and a shell holds one stream per open terminal.

That report is the only oracle the coalescing rule has. Nothing crosses to the
page saying how much was held or how many frames its bytes arrived inside, and
both ways a held stream dies reach the page as `overflow`, because a reason its
reader has never heard of is a frame it drops. In production the two rules were
indistinguishable. They are now a line each, per stream.

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

* test: give the renderer fixture the source its serializer returns

`serializeRendererTerminalBuffer` answers `renderer`, and vitest does not
typecheck, so the stub's `headless` passed every run and failed the node
typecheck instead.

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

* fix: budget the frame the publication actually sends (OTA phase C, C7.3)

The budget and the publication were written out twice, five lines apart, and had
drifted at every site: a budget for `{kind:'scrollback'}` approved a frame sent as
`kind:'resized'` with a `reason` beside it, and the live module budgeted
`pending-output-overflow` while sending `renderer-mount-ready`. It held only
because the padded `requestId` and `seq` are absent from those frames and more
than covered the difference. Each site now builds one object and hands it to
both.

`displayMode` cannot travel that way and was a third under-measure nobody had
named: the subscribe flow re-reads it from the runtime after the snapshot is
serialized and before the frame is sent, so no caller can tell the budget which
mode the publication will carry. It joins `seq`, `requestId` and the truncation
flags as a field taken at its widest. The mode list resolves the constant to
`never` if the runtime gains a mode it does not carry, so a new one is weighed
here rather than found on a phone.

Red-first needed a second attempt: the first fixture had trimming slack, so three
extra bytes fit and the probe could not see the defect it was written for. The
case now budgets a fixed screen at exactly its `auto` measure, where the margin
is the whole of the test.

One figure for the overshoot everywhere, with its basis: 169 bytes over the
655,360-byte cap on a frame carrying an 8-character request id, 247 with a
24-character one. Three places said 169 and one said 173.

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

* refactor(mobile): delete two backlog guards no input can reach

Both survived mutation because neither is reachable, and neither became
reachable when I tried to write a case for it.

`next` narrowed the merge ceiling to one frame, but its only caller,
`drainTerminalBacklog`, has already narrowed it: the parameter is what one
payload may occupy, not what the window holds, so the second narrowing could
never change the answer. The parameter now says so and the class no longer needs
the frame size at all. The bound still lives in the caller and is still covered:
removing it there reds a delivery case.

The merge run also compared stream ids, but a backlog belongs to one subscription
and every `data` payload on it carries that subscription's single stream id, so
the comparison could not fail. The run still stops at anything that is not
output, which is reachable and pinned.

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

* docs(mobile): record the invariant the deleted stream-id guard rested on

The merge run compares no stream ids because it cannot need to: a backlog belongs
to one subscription and every `data` payload reaching it carries that
subscription's single stream id. Written down where the run is, because the thing
that would break it is a change made somewhere else — multiplexing two streams
onto one record would merge their output into one payload under the first id.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 10:19:41 -04:00
Jinwoo Hong e736a9f29b feat(mobile): take the session screen's inputs, links, clipboard and routers through the platform seams (OTA phase C, C7.2) (#21790)
* feat(mobile): put the session screen's nine text inputs on the web font seam (OTA phase C, C7.2)

The landed text-input census, run over `app/h/[hostId]/session/[worktreeId].tsx`,
reports nine sizes that do not come from `TEXT_INPUT_FONT_SIZE`. Six declare the
app's body size and move in place, which is the same number natively. Three do
not — a 22px key-capture field and the chat's two 15px fields — so each gets a
`.web.ts` sibling of the address bar's shape, with a shared base so the two
halves can differ in nothing but the size.

The capture field is the one the move shrinks rather than raises: 22 already
clears the focus-zoom floor, and the census reads the seam as a binding rather
than as a number, so there is no expression that keeps 22 and still says where
the size came from.

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

* fix(mobile): drop the theme import the composer's style split left behind

`oxlint` over the whole tree, which CI runs, reads it as an error.

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

* feat(mobile): open the session screen's three external URLs through the platform seam (OTA phase C, C7.2)

The landed external-link census, run over the session route's closure, reports
three modules reaching react-native's `Linking`: a terminal link tap whose open
mode is the phone's browser, and the two WebView-backed readers, each of which
sends a tapped link to the system browser rather than navigating the artifact
away.

Inside the shell `Linking.openURL` calls `window.open`, which both shells refuse
and which resolves either way, so all three reported success into a tap that did
nothing. The seam also stops swallowing the failure: each site caught and
discarded, and `openExternalLink` names it.

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

* feat(mobile): take the session screen's seven clipboard sites through the platform seam (OTA phase C, C7.2)

`expo-clipboard` resolves to `navigator.clipboard` on the web, which needs a
secure context — iOS serves the page from a custom scheme and Android from
`https`, so that path works on one platform and silently not on the other. The
landed census now reports the module out of the route's closure entirely.

The seam grows its reader half, on the landed `native.clipboard.read` verb: text,
a PNG, and a presence probe. Two degradations are recorded rather than implied.
No shell serves an image, so the page answers null and the terminal's paste takes
the branch an empty clipboard already took; and the shell serves no presence verb,
so `contents` answers what this side knows rather than reading to find out, which
would raise iOS's paste-consent prompt on every foreground.

The copy-path sheet gains the failure toast its two neighbours already had: it
showed "Path copied" before the write, and the seam rejects rather than returning
false.

The route parity pin moves with it: five clipboard hooks join the expanded route
and one runtime string joins the sheet.

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

* feat(mobile): take the session domain's three routers through the handoff seam (OTA phase C, C7.2)

Inside the page a screen is one document standing in for one screen, and
`useRouteHandoff` is the only thing that knows which targets the page keeps and
which it hands back to the app. The three holders here are the workspace-missing
bounce, the file-tap preview push, and the pane-tap param consume.

The domain's census is narrower than the two landed ones because it has to be:
eight of its hooks take `useFocusEffect` and two take `useLocalSearchParams`,
neither of which can navigate, so the rule is a closed list of names rather than
a ban on any value import — which also catches expo-router's module-singleton
`router`, a spelling a `useRouter` rule would have read as clean.

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

* feat(mobile): list the two style siblings the session screen's inputs added (OTA phase C, C7.2)

The overrides census fails on an unlisted `.web.*`. One raises the chat's two
15px fields past the focus-zoom floor; the other lowers a 22px capture field onto
the seam, and its entry says why a reduction is the right answer there.

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

* feat(mobile): let the text-input census read a literal already clear of the floor (OTA phase C, C7.2, ruling 12)

The floor is the rule and the seam is the mechanism. A binding rule alone made
the custom-key capture field an offender at 22, where nothing can zoom, and the
only way to satisfy it was to lower a one-character field to 16 — the tail
wagging the dog.

The seam's web half now exports the floor it already computed `Math.max` against,
and the census reads that number out of that file rather than carrying a second
copy of 16. The rule becomes "the seam's binding, or a literal at or above the
floor", with no per-site exemption: a literal under the floor is still reported,
which is the case the seam exists for. A tree whose seam declares no floor is
refused rather than judged against a number the census invented.

So the capture field goes back to 22 on both platforms and its split, its
override entry and its parity test go with it. The chat's two fields stay split,
because 15 is under the floor however it is spelled.

Red-first: with the rule removed, a planted literal 16 and a literal 22 are both
reported and the refusal case does not throw; a literal 15 is reported either
way. All three route closures that run this census — session, source-control,
review — report 0 offenders and 0 unresolved.

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

* fix(mobile): answer clipboard reads on the read grant and catch refused writes

`contents()` reported no text on a route that granted `native.clipboard.read`
without the write, because it read `verbs.granted`, which is write AND read. The
verbs hook now exposes the two grants separately and the web seam answers on the
read one; `granted` keeps its meaning for the callers that need both.

The Markdown copy action was the one write of eight in the session domain with
nowhere for a rejection to go: the seam rejects when the pasteboard refused the
text, the callback had no failure branch, and its caller drops the promise, so a
refused write raised an unhandled rejection and still left "Copied" on screen. It
now takes the error haptic and the "Couldn't copy" toast the other copy paths
show. A census over `src/session` fails if any `writeText` call site lacks a
failure branch, so the ninth site cannot arrive without one.

The route parity pin moves with it: one callback body, one runtime string. Its
refresh note claimed six clipboard hook sites for a delta of five; the walk from
`SessionScreen` reaches five, and the terminal's paste is not among them.

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

* fix(mobile): probe both clipboard kinds at once and judge sizes against the floor

Moving the two clipboard probes into an object literal serialised them: the
migrated `contents()` awaited `hasStringAsync` before `hasImageAsync` was called,
where both callers had used `Promise.all`. That path runs on mount, on every
AppState foreground and on every select-mode toggle. Restored, with an ordering
probe that deadlocks unless both probes start before either answers.

The floor case could not fail for the reason it named: its fixture declared 16,
so a census carrying its own copy of 16 passed it. It now plants a seam declaring
20 and a literal 18, the size that is clean under one floor and an offence under
the other.

Two stale wordings from the reverted split: one closure case still said "both
split style modules" over a one-element list.

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

* fix(mobile): buzz the quick-command row when a copy is refused

The last of the seven migrated writes without the error haptic. The row already
said "Couldn't copy" on its own control, in red, for the 1500 ms the toast the
other six show would have lasted, so it never claimed a refused write had landed;
what it had no way to say was anything the thumb still on the button could feel.

Its first test, on the harness its list already uses: the seam rejects when the
pasteboard refuses, and the two cases are the difference between the row that
shows a green check over nothing copied and the row that does not. The list's own
test gains the haptics mock the row's new import needs.

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

* fix(mobile): make the clipboard census require the await its rule depends on

`hasFailureBranch` accepted any enclosing `try` with a `catch`, so the one shape
the census exists to stop passed it: `void clipboard.writeText(...)` inside a
try/catch is an unhandled rejection with a handler three lines above it that can
never run, because the block returns before the promise settles. It now requires
the call to be awaited inside the try's own block, or to carry a `.catch` along
its own chain. The boundary walk stopped only at function and method
declarations, so a `catch` outside an arrow answered for the call left running
inside it; every function-like node ends the search now.

Five cases over snippets read through the same reader, because a `void` write
would have to be committed to be tested against the real tree. Control on a real
site: making the Markdown write un-awaited inside its own try reports it.

Two provenance fixes. The runtime-string delta across C7.2 is two literals, not
one: "Couldn't copy path" took the count from 532 to 533 and "Couldn't copy" took
it to 534. And main's C7.4 made `BRIDGE_CLIPBOARD_MIMES` `['text']`, so an image
mime is a value the schema does not admit rather than a refusal the verb spells
out, with `native.media.pick { source: 'clipboard' }` waiting on C7.6; the web
seam and its test said otherwise. Behaviour unchanged.

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

* fix(mobile): let an unmounted quick-command row emit nothing on a refusal

The haptic I added ran before the mounted guard, so a copy pressed on a row that
then scrolled out of the list, or a sheet closed over it, still buzzed when the
rejection arrived. A buzz with no row to explain it is feedback for nothing, and
the guard was already there for the feedback state one line below.

Red-first: press, unmount, then reject. The success path already guarded first.

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

* fix(mobile): make the clipboard census require a catch with something in it

Any `.catch` property access counted as a failure branch, so two shapes that
handle nothing passed: `clipboard.writeText(text).catch` reads the handler's name
and registers nothing, and `.catch()` swallows the rejection while the caller goes
on to say the write landed. The rule now requires `.catch` to be the callee of a
call carrying at least one argument.

Red-first with both shapes in the snippet reader, the accepting cases unchanged.
Control on the real tree: emptying the notes sheet's handler reports
`MobileSessionSheets.tsx:174`, and restoring it greens.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 09:49:59 -04:00
Jinwoo Hong e5181113ca feat(mobile): add the media verbs the page's pickers and paste will use (OTA phase C, C7.4) (#21779)
* feat(mobile): add the media verb rows to the shell's native table (OTA phase C, C7.4)

`native.media.pick`, `native.media.readChunk` and `native.media.release` join
`BRIDGE_NATIVE_VERB_NAMES`, so each becomes a grant name `init.grants.native` can
carry and each gets a strict zod contract in `bridge-media-verbs.ts`. A chunk read
is held to the upload path's own budget, imported rather than restated: the leaf
module `mobile-clipboard-image-upload-chunk.ts` now declares
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` so the bridge can read it
without pulling the upload path's RPC operations into every page closure.

Inert: no page calls the verbs and no route declares them, so an older page never
names them and an older shell refuses them as `unknown-verb`.

`native.clipboard.read`'s `{ mime: 'image' }` refusal is retired rather than left
pointing at nothing. The broad shape existed so a later build could serve an image
without a contract change; that build is `pick { source: 'clipboard' }`, which
stages the image behind a handle instead of inlining 24 MiB of base64 through an
8 MiB reply. The mime enum is text only, the handler's out-of-scope arm is gone,
and the refusal's test flips to the new answer: `invalid-params`, before dispatch.

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

* feat(mobile): give the shell the staged-media handle registry (OTA phase C, C7.4)

A handle names a file this shell copied into its own cache, and such a file has no
owner otherwise: the page that asked for it is a document that can navigate, fault
or be swiped away without telling anyone. So the lifetime is bounded four ways and
all four are here — the page's own `release`, `MEDIA_HANDLE_TTL_MS`, and
`releaseAll`, which both the session's end and the page's unmount reach through
`useMediaHandleRegistry`. The TTL is measured from the last touch, not the mint, so
a page reading a large file one chunk at a time cannot have it swept out from under
it; its five minutes is sized for the page that picked and then stopped, not for
the read, which is 48 round trips on the largest item a pick may stage.

`BRIDGE_MEDIA_MAX_LIVE_HANDLES` caps what one session holds. A pick that would pass
it is refused whole and discards what it staged: half a multi-select is an answer a
page cannot tell from a user who picked fewer.

Three refusals join the seam's vocabulary. `native_media_handle_unknown` covers
never-minted, released and swept alike — which of the three it was is a fact about
another page's pick. `native_media_range` refuses a read at or past the end of an
item that had bytes, because the previous chunk already said `eof` and an empty
answer would let a page loop instead of failing where the bug is.
`native_media_handle_cap` is the cap above.

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

* feat(mobile): serve the media verbs on the device, behind staged handles (OTA phase C, C7.4)

`pick` runs the OS permission prompt inside the shell — `expo-image-picker` for the
library, `expo-document-picker` for Files, the pasteboard for `clipboard` — stages
each item as a file this shell owns, and answers handles. `readChunk` reads the
byte range the registry hands it and base64-encodes it through the upload path's
own accumulator; `release` deletes the file.

The device calls live in `native-media-device.ts` and nothing else. Importing
`expo-image-picker` imports React Native, so a module naming it cannot be driven in
a unit test at all, and the arms worth pinning are exactly the ones a simulator
makes expensive: a denied permission, a cancel, an item over the staging ceiling.
`native-media.ts` takes those seven calls as dependencies and is tested whole.

Two refusals join the vocabulary, each because a page acts differently on it.
`native_media_permission_denied` is a permission the user can still grant in
Settings, not a library the shell could not read. `native_media_too_large` is an
item over `MEDIA_STAGED_MAX_BYTES`, weighed from the staged file rather than from
what the picker declared, since a picker's own size is optional on both platforms.
A pick refused that way discards every file it staged.

Each chunk is base64 on its own, so a page concatenates decoded bytes and never
strings: only the last chunk of a read ends on a partial group.

Measured: the largest reply this verb can produce is a full
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` chunk, 524,427 bytes on the
wire with its envelope — one frame, 80.0% of the 640 KiB frame cap and 6.3% of the
8 MiB reply ceiling. Pinned in `native-media.test.ts`, so a cap or an envelope
field that moves shows up as a diff.

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

* fix(mobile): only mint a media handle over a file this shell owns (OTA phase C, C7.4)

The Android arm of the same handler. Both pickers are configured to hand back a
copy in this app's own cache — `expo-image-picker` always copies, and
`expo-document-picker` does under `copyToCacheDirectory` — and the whole handle
contract rests on it: `release` is a delete, and so is the TTL sweep. A provider
that answered `content://media/...` instead would mint a handle over a file this
shell can neither size nor delete, and every sweep would be a silent no-op that
leaves the cache growing. `ownsStagedMediaUri` refuses that where the assumption
is made rather than letting it surface as a cache that never empties.

`native-media-device.test.ts` pins the options that make the assumption true: the
document picker's `copyToCacheDirectory`, the library picker's `base64: false` and
its selection limit, the pasteboard's png, and the cache file `stageBase64` writes.

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

* test(mobile): move a picked image across the real pair, chunk by chunk (OTA phase C, C7.4)

Through `createFakeBridgePortPair` rather than the host alone, because a chunk is
the one reply this seam produces that is within a rounding error of the frame cap:
a case that called the server directly would never serialize one, and serializing
is where a reply that fits and a reply that splits part ways.

A granted page picks an 18 MiB item, reads all 48 chunks in order to `eof` and
releases it; the decoded length is the file's own and both ends are the fixture's,
which a reader that dropped or reordered a chunk would not have. Nothing reaches
the desktop client. A page granted only `navigate` and `storage` is refused
`native_verb_ungranted` with no picker run — the refusal is the host's, because
the page side of these verbs is C7.6's. A released handle, a swept handle, a read
past the end and a pick over the live-handle cap each come back under their own
code.

The pair's default verb handler answered a clipboard shape for every verb, so a
media call through an unconfigured pair came back as `native_verb_result` — a
shell bug's code for a harness that was never told about the verb. It now answers
one shape per row of the table.

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

* test(mobile): drop the four type assertions the changed-code gate found (OTA phase C, C7.4)

`new Array<T>()` for the two hoisted mock ledgers, and the probe holds its handler
in a record it null-checks rather than asserting one that a render might not have
produced.

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

* fix(mobile): rename the chunk verb to native.media.read, which a manifest may name (OTA phase C, C7.4)

`native.media.readChunk` is not a legal grant name. `GRANT_NAME_PATTERN` holds every
`native.` segment to `[a-z][a-z0-9]*`, so a route declaring it fails
`MobileWebBundleRouteSchema` — and `bundled-mobile-web-bundle.ts` parses the manifest
whole, so the camel-cased segment is not a route that degrades to its native screen.
It is a bundle the phone rejects entire. Params are unchanged.

The test that would have caught it now reads the schema itself rather than a copy of
its pattern: every entry of `MOBILE_WEB_SHELL_GRANTS`, and every name in
`BRIDGE_NATIVE_VERB_NAMES`, parsed as the grants of a manifest route. Both lists,
because the spread is what makes them agree today and a build that stopped spreading
would leave this the only thing that noticed. Red on the old name with the exact
message: `native.media.readChunk: expected false to be true`.

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

* test(mobile): exercise a short read, which nothing separated from the chunk cap (OTA phase C, C7.4)

Every other read in the suite asks for a whole chunk of a file shorter than one, so
a handler that ignored `length` and read to the end passed all of them: the fake
file clamps at its own size. Replacing the range with the cap left 74/74 green.

A 16-byte read at offset 400 of a 1000-byte item now pins the length, the offset and
`eof: false`. Under that same mutation it reds with `expected 600 to be 16`, and it
is the only case that does.

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

* test(mobile): kill the four device arms that survived mutation (OTA phase C, C7.4)

Each was green with the arm removed; each now reds with it removed and nothing else
does.

The data-url strip: `getImageAsync` answers `data:image/png;base64,...`, which is
what an `<Image>` source wants and not what a file wants. Staged unstripped, every
byte is shifted by the prefix and the page decodes a corrupt image with no error
anywhere. The old fixture had no prefix at all.

The cancel flag: both pickers answer `assets: null` beside `canceled: true` today,
so a handler keyed on the list alone passed every fixture here. The new case sends
the flag with a populated list, which is what a picker version that changed its mind
would send.

The unknown mime: an empty string is not a mime the result schema takes, so the
alternative to the floor is `native_verb_result` — a shell bug's code for a document
picker doing what it may do.

The handle close: a ledger per opened handle, asserted on the way out and on the way
through a reader that throws. A file handle a shell leaks is invisible on a fake and
a file descriptor on a phone.

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

* test(mobile): pin the stated constants as literals (OTA phase C, C7.4)

The TTL, the live-handle cap, the handle and mime lengths, and the 48 chunks an
18 MiB item reads in. Literals, not the constants restated: every one is a number a
body claims and a reviewer checked, and read through its own name the assertion
would hold whatever it became — which is what the 524,427-byte reply pin already
does for the frame it measures.

Each reds when its number moves: 8 to 12, 64 to 32, 128 to 256, five minutes to ten.

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

* test(mobile): name the fixture by its size, assert the clipboard refusal, reach the clamp (OTA phase C, C7.4)

Three minors from round 1.

The case that said "18 MiB" staged a million bytes. Renamed to what it stages, with
a line saying the largest item a pick may hold runs in the port-pair suite, where
the frames are serialized and the size is the one that matters.

The clipboard image case had been weakened to a bare `.rejects.toThrow()`, which
would pass for a handler that reached the pasteboard and failed there — the one
outcome it exists to rule out. It now asserts a `ZodError` naming the `mime` path,
and reds when the parse is taken out.

The registry's chunk clamp was unreachable behind the params schema. Kept and
reached rather than deleted: the two bounds are different promises, one saying what
a page may ask for and one saying what the registry will hand any reader, and
`read` is a public method its own suite already calls directly. A case asks for
three chunks at once and gets one; it reds when the clamp goes.

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

* fix(mobile): copy a provider uri into the cache instead of refusing the pick (OTA phase C, C7.4)

`MediaHandler.readExtras` in expo-image-picker 55.0.24 has a reachable arm: when
`toMediaType` cannot resolve a MIME it answers
`ImagePickerAsset(type = null, uri = uri.toString())`, the provider's own
`content://` uri, uncopied. The OS completed that pick, so refusing it as
`native_verb_failed` loses a photo the user chose — and the comment above the guard
claimed both pickers "always" answer `file:`, which the same file disproves.

The pick now copies what it does not own into the shell's cache and mints the handle
over the copy, which is what the body already claimed. The guard stays for anything
the copy could not adopt, and that arm still fails closed: a sweep that can never
delete anything must not look like one that did. The failure path discards what this
shell owns rather than the uris the picker answered, since a provider's uri was
never ours to unlink.

Copied through `bytes()` rather than `copy()`: `FileSystemPath.copy` goes to
`javaFile.copyRecursively`, a `java.io.File` with nothing to open for a provider uri,
while the read path goes through the unified file and does. A source that cannot be
read takes the empty destination with it, because the caller never learns that name.

Red-first: a fixture answering `content://media/external/images/media/42` asserting a
handle over a `file:` copy, its 300 bytes read back whole, and the copy being what
`release` deletes.

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

* fix(mobile): bound a multi-select to the room the registry has left (OTA phase C, C7.4)

`selectionLimit: 0` means unlimited to the OS picker, and `mint` then refuses the
whole pick past `BRIDGE_MEDIA_MAX_LIVE_HANDLES`. A user who chose nine photos waited
through the OS copying every one of them into the cache to be told none were taken.

`pick` now reads `remainingCapacity()` before it launches anything: an empty room is
`native_media_handle_cap` up front, for every source, with no picker run and no byte
copied. What room is left becomes the picker's `selectionLimit`, so the selection
cannot exceed what the registry will accept and the refusal after the fact is only
reachable by a page that never released what it holds.

`getDocumentAsync` takes no selection limit, so for Files the room is the up-front
refusal only; `mint` remains the bound that cannot be skipped.

Each pin reds on its own mutation: dropping the up-front refusal, putting
`selectionLimit` back to 0, and fixing the limit at the cap instead of the room.

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

* fix(mobile): ask for the library permission on iOS only (OTA phase C, C7.4)

Read from expo-image-picker 55.0.24, not from the docs. Neither platform's
`launchImageLibraryAsync` gates on a permission: `launchCameraAsync` calls
`ensureCameraPermissionsAreGranted` on Android and `hasGrantedPermission` on iOS, and
the library arm goes straight to its contract on both. On Android
`getMediaLibraryPermissions` answers an empty array from API 33, so the request
prompts nothing and always resolves granted; below 33 it asks for
`READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE`, which the system picker never
reads, and a denial there reached `native_media_permission_denied` for a pick the OS
would have completed. Android no longer asks.

iOS still does, because the prompt inside `pick` is what ruling 6 asked the shell to
own and the photo-library dialog is a real thing a user sees there.

One finding for the lead rather than a silent change: with `allowsEditing: false`
the iOS path is `launchMultiSelectPicker`, a `PHPickerViewController`, which also
opens without authorization. So the iOS request is the ruling's and not the SDK's,
and a denial refuses a pick that would have worked. Dropping it is a ruling-6
amendment, not mine to make.

Both arms pinned and both red under their mutations: asking everywhere fails three
cases, asking nowhere fails two.

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

* test(mobile): mock react-native in the device-verb hook suite (OTA phase C, C7.4)

The hook reaches `native-media-device.ts`, which now imports `Platform`, and reaching
the real module imports React Native — which this suite has no runtime for, so it
failed to load and ran zero tests while the run still reported every other file
passing. The device half's own platform arms are `native-media-device.test.ts`; here
the OS only has to be one.

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

* fix(mobile): ask for no library permission on either platform (OTA phase C, C7.4)

Ruling 6b. `launchImageLibraryAsync` gates on nothing in expo-image-picker 55.0.24:
Android goes straight to its contract and iOS to `launchImagePicker(.photoLibrary)`,
and only the camera arm checks a permission. The prompt "inside the shell" that
ruling 6 asked for is the OS picker's own, so `pick { source: 'library' }` adds
none. `requestLibraryPermission` is gone from the deps, from the device calls and
from `pickFrom`, and with it the last way a denial could refuse a pick the OS would
have completed — Android below API 33 for storage permissions the picker never
reads, iOS for a `PHPickerViewController` that opens without authorization.

`native_media_permission_denied` stays in the refusal vocabulary, documented for the
first source that needs one rather than for a producer this build has.

Red-first, and driven through the whole verb rather than through `launchLibrary`
alone, since the request was `pick`'s and a case that only called the picker would
have passed either way: with the request restored, the pick on iOS calls the
permission API once and the deps still carry the member, and both cases red.

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

* fix(mobile): sweep before release, and name one basis per ratio (OTA phase C, C7.4)

Two lows from round 2.

`release` was the only lifetime path that did not sweep first, so an expired handle
was still in the map when it looked and the page was told `released: true` for a file
the next sweep would have taken anyway — the opposite of what
`mediaReleaseParamsSchema`'s docstring promises it. It sweeps now, like every other
path that reads the map. Red-first with the reviewer's own probe: expired, then
released, answered true. The exact boundary is pinned while we are here, because the
sweep's comparison is inclusive and nothing said so: at exactly `MEDIA_HANDLE_TTL_MS`
the handle is gone, one millisecond earlier it is not.

The raw ceiling was called "three times the reply ceiling" in two places.
`CLIPBOARD_IMAGE_MAX_SOURCE_BYTES` is 18,874,368 against 8,388,608, which is 2.25;
three is the ratio after base64 expands it. Both sentences now name which basis they
are on, and say that the two differ.

Each pin reds on its own mutation: dropping the sweep from `release`, and loosening
the sweep's comparison to exclusive.

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

* fix(mobile): never sweep a uri this shell does not own, and weigh before copying (OTA phase C, C7.4)

Round 3's five items.

(1) `owned.push(uri)` ran before the ownership guard, so a copy that could not be
adopted sent the provider's own `content://` uri to the failure path's discard — the
one thing the array's docstring says it never holds. The push now happens below the
throw, inside a named `adopt` that either hands back a uri this shell owns or throws
having claimed nothing. Red-first: the refusal case discarded exactly
`["content://media/external/images/media/42"]`.

(2) The copy's docstring claimed the in-memory read was bounded by a ceiling checked
"right after", which ran after the whole `bytesSync()`. The source is now weighed
before the copy, which is real: `FileSystemFile.size` routes a `content:` uri to
`SAFDocumentFile.length()`. A provider reporting nothing answers 0 there, so the
docstring also says what is true for that case — the read is bounded only by
`mediaTypes: ['images']`, and the copy is weighed after the fact instead. The copy's
own size stays authoritative for the handle.

(3) Both `react-native` mocks went inert when `324c4093be` dropped `Platform`. Gone,
along with the two-platform loop that had become one code path run twice; the 6b
guard now reads "never calls `requestMediaLibraryPermissionsAsync`", which is what it
was checking.

(4) The hook case no longer claims a permission prompt.

(5) The 150-character comment is wrapped, along with the three other comments over
100 that I had authored in this file and the two platform modules.

Each behavioural pin reds on its own mutation: pushing to `owned` above the throw,
and dropping the pre-copy ceiling check.

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

* fix(mobile): refuse what cannot be weighed, and count a Files pick before staging it (OTA phase C, C7.4)

(a) The pre-copy weigh is now a mechanism rather than a docstring, and the branch it
takes is the one the SDK allows. Verified in expo-file-system 55.0.26: a `content:`
uri reads its size through `SAFDocumentFile.length()`, so the common case is weighed
before `bytesSync()` materializes anything. There is no bounded read to fall back on
for the rest — `File.open()`, `readableStream()` and `writableStream()` all reach
`FileSystemPath.javaFile`, which throws `This method cannot be used with content
URIs` outright, leaving `bytesSync()` as the only read a provider uri has and it is
all or nothing. So an item whose provider reports no size, which answers 0 and is
indistinguishable from an empty file, is refused rather than read at an unknown
size. An empty pick was nothing to stage either way.

(b) `getDocumentAsync` takes no selection limit, so the room `pick` hands it was
advisory and a user could return more than the registry holds. Counted before
staging: previously all nine assets were copied into the cache and `mint` refused the
lot, which the red run showed as nine discards. The refusal is
`native_media_handle_cap` and it sweeps nothing, because nothing of this shell's
existed yet.

Three pins, each red on its own mutation: dropping the unweighable refusal, making
the room check a no-op, and taking it off the Files arm.

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

* test(mobile): drop the last two traces of the library permission prompt from the media tests

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 08:32:52 -04:00
Jinwoo Hong e6aa90ff36 test(mobile): certify the browser pane's golden families and render it in a page (OTA phase C, C6.5) (#21777)
* test(mobile): pin the browser pane's golden families

The half pin for C6: 4 families, 15 goldens, every verdict the one C2's
rule predicts. Measured per family with vitest `-t` over the full
787-golden corpus, with C1's 103 reproduced golden-for-golden as the
control: 6 byte-identical, 9 result-absent-settlement.

No composed `c6-page-closure.ts`: a composed table is pinned against a
route and the browser is a pane, so C7's route is what composes this
with C1's.

The derivation census does not wait for that route. `mobileWebAppRoute-
Closure` becomes one case of `mobileWebAppModuleClosure`, which takes
any entries, so the pane's own closure can be read from the module. Two
cases: the pane alone reaches exactly the pinned four, and the pane
beside `app/h/_layout` adds exactly those four and no other, with the
layout reproducing C1's 22 as the control for the difference.

Closure at this base: 48 local modules alone, 34 beyond the layout, 30
under `src/browser` and four through the web siblings. The design said
23, all under `src/browser`; it was measured before C6.2 and C6.3 added
those siblings, so the pin carries the re-measured number.

`browser.screencast` has no golden at all, so this certifies the input
path and says nothing about the frame path.

Red first: with `browser.wheel` dropped from the table, both census
cases fail naming the missing family; restored, the file's 10 cases pass
and the parity suite reports "15 goldens in 4 families, 6 byte-identical".

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

* test(mobile): pin the frame budget against the shell's real frame

Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's
device scale from a skeleton it builds itself, and until now its only
check was another skeleton of the same shape in the same file: two
copies of one assumption agreeing with each other.

This measures the real thing. A frame with CDP's nine metadata fields
and a real `Page.screencastFrame` timestamp, encoded by C6.1's
`encodeBridgeScreencastFrame` and serialized by the real
`BridgeHostSubscriptions`, posted through the host harness: 303 bytes
besides the image, against a bound of 516.

Held above is not enough on its own — 213 bytes of slack is room for the
shell to grow the envelope by a field the page never hears about — so
the bound is reconstructed exactly instead. Every byte of that slack is
a number this frame prints narrower than a double can; adding those back
gives 516 on the nose.

The budget cases run a generated noise image at the budgeted scale, not
a committed fixture: the worst case is the image JPEG compresses least,
and a photograph sits a tenth of the way to it. 901,161 px at 0.545
bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of
the 655,360-byte cap. One envelope more and the shell drops it, which is
ruling 1 read from the budget's side.

Red first, two ways. Drop the metadata widening from the bound and three
cases fail, the sharpest being the real shell answering the frame the
page thought it could send with zero posts. Add a field to the shell's
own envelope and the reconstruction fails at 516 against 548, where the
existing suite stays green on all 14 — which is the drift this file
exists for.

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

* docs(mobile): record the measured frame bytes, correcting e7cd24ef10

The previous commit message says the shell posts 654,857 bytes for a
frame at the budgeted area. That number was not measured; I wrote it
from the budget arithmetic instead of reading it off the harness. The
measured value is 655,147, which is 213 under the cap rather than 503.

Nothing in the assertions changes — they compare against the cap and
the bound, never against a literal — but the figure now lives in the
file where it was measured rather than only in a message that has it
wrong.

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

* test(mobile): render the browser pane in a page and paint a real frame

The only place C6's whole frame path runs. Every other check reads one
half: the shell suites drive the host with no page, the page suites
drive the hooks with no shell, and the parity pin certifies the input
path from a recording.

Ruling 4: no route is added. `bundleMobileWebApp` already takes an
`appDir`, so this builds a one-route tree of its own and nothing under
`mobile/app` moves. The shell double grows a screencast lane to serve
it: it accepts a subscribe, posts `event.binary`, and prices each frame
the way `BridgeHostSubscriptions` does, so an over-cap frame is dropped
where the page can watch the stream survive it.

Six cases: the pane subscribes with `wantsBinary` and paints the frame
it is handed; a second frame flips the double buffer; an over-cap frame
is dropped and the next one paints on the same subscription; the grant
withheld produces the update-the-app copy and no subscribe at all; a tap
issues one `browser.mouseClick` at the centre of the source viewport;
and no request leaves the bundle's own origin.

Measured. The frame the pane asks this viewport for is 390x698, which as
noise is 201,924 base64 characters. The phone's mobile-mode frame is
780x1424 and encodes to 811,168, which is 124% of the cap and the reason
the area budget exists; the over-cap case uses 2400x2160 at 3,761,580,
574% of it. The tap maps to (194, 356) against a 390x712 source, one
device pixel off centre because the rendered width is 382.33 CSS pixels
for 390 source pixels.

One finding, recorded rather than fixed because it is not the pane's.
The page files a CSP `script-src` violation on every load, on any route:
Zod 4 feature-detects its compiled path with `new Function('')`, the
shell's `script-src 'self'` blocks it, Zod catches the throw and takes
the interpreted path. The page is correct and the report is filed
anyway. One case names it so a second `eval` is visible, and every other
case asserts no violation beyond it.

Red first, twice, both by reverting behaviour C6.2 landed. Stub out the
decode probe in `whenBrowserFrameDisplayable` and the flip case fails on
two identical frame digests. Point `updateBrowserImageSource` at the
host element instead of the surface child and the paint and flip cases
both fail. The first case's comment is corrected by the first of those:
it claimed a visible layer proved the decode-then-flip, and the frame
still paints with the probe gone, so the flip case is what proves it.

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

* fix(mobile): sweep the worst-case JPEG cost instead of taking one point

The reviewer is right, and it is worse than the report says. At 0.545
bytes per pixel, 90 of 143 viewports posted a frame over the cap, and
59 of the 111 the budget claims to fit were dropped outright by the
real shell — 390x712 at scale 1.8 among them.

The old number came from one 2400x2160 frame. A single large frame is
the cheapest per pixel in the whole range, so a worst case measured
there is not a worst case anywhere else.

Swept 143 viewports, widths 320 to 1400 and heights 480 to 1600, each
encoded by Chromium at the scale the real budget picks for it. Across
the 111 the budget fits, the cost ranges 0.54470 to 0.55351 bytes per
pixel. The constant is now 0.56: that maximum plus 0.00649, about 1.2%,
for the encoder version it was not swept on. The docstring carries the
sweep, the range, the margin and the date.

0.56 is a fixed point, not a guess. Raising the constant shrinks the
budget, which lowers the scale, which moves the cost; 0.555, 0.56 and
0.565 all leave the same 31 viewports over the cap, and every one of
those sits at the scale floor of 1, where the module already declines
to go blurrier and C6 ruling 1's drop rule is the protection. The new
test asserts both halves: nothing the budget fits goes over, and the
largest viewport it cannot fit is dropped by the real shell.

The sweep lives in `config/scripts` because it needs Chromium: the
frames are CDP screencast frames, so Chromium's encoder is the oracle
and a Node JPEG library would calibrate against the wrong bytes. It
drives the real budget, the real scale function and the real
`BridgeHostSubscriptions`, and runs in about 4 seconds.

Ruling 2's block in `browser-screencast-budget-at-the-shell.test.ts`
now says plainly what it measures. It feeds `noise(area * theConstant)`,
a byte count the constant itself produced, so it can falsify the
expansion and the drop rule but never the constant. It read as if it
validated the worst case, and it did not.

Red first: put 0.545 back and the sweep fails with 59 viewports, each
naming its scale and reporting `null` — the real shell dropping the
frame rather than posting it over the cap.

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

* fix(mobile): turn Zod's JIT probe off for the page, before any module

The page filed a CSP `script-src` violation on every load: Zod decides
whether it may compile by constructing `new Function('')` and reading
the throw as "no JIT here", the shell's `script-src 'self'` is exactly
that throw, and the browser reports it before Zod catches it. Zod's own
source gates the probe on `jitless` for this case.

`z.config({ jitless: true })` at the entry does not work, and the
reviewer's suggestion of putting it there was measured losing the race.
`$ZodObject` reads `allowsEval` when a schema is constructed, not when
one is parsed, so the first module-scope `z.object(...)` in the bundle
fires the probe — and esbuild evaluates the chunk holding zod and its
callers before the chunk holding any module of ours that imports zod. A
Function-constructor trap in the page put the call under `new ZodObject`
ahead of the entry's first statement.

`globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with
`??=` rather than replacing, so the banner can set the flag before any
module runs. That is where it now lives, beside the `process` shim and
under the same `MOBILE_WEB_APP_SHIMS` contract, which asserts it is
applied. Nothing is lost: the compiled path was never reachable in a
page under this policy.

The render check's `newCsp()` filter is gone. It dropped violations by
`blockedURI === 'eval'`, which would have hidden a real one, and every
case now asserts zero. The first case walks load and first paint, which
is where the second of the two reports fired. The dead `violations`
array is deleted.

Red first: blank the banner constant and four of the six cases fail,
each naming a `blockedUri: 'eval'` the filter used to swallow.

Finding, not fixed here and reported instead: the page bundles two
copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, because
`src/shared/zod-salvage.ts` resolves upward. That is 808 KB of duplicate
source. Aliasing `zod` to one copy in the builder fixes it and was
measured working, but it changes which zod shared code runs in the
shipped page, which is a call to make on its own rather than inside a
CSP fix.

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

* test(mobile): give the shell double the whole canCarry rule and the acks

The double reproduced one arm of `BridgeHostSubscriptions.canCarry`, the
message cap, and silently carried anything the other two would have
refused: a window already holding its maximum frames, and a window whose
bytes the frame would push past the limit. It also ignored the page's
`ack` frames, so its window never reopened — which was invisible only
because no case streamed far enough to close it.

Both arms are in now, and the `ack` arm consumes the page's acks exactly
as the host does. The three caps are read out of `bridge-caps.ts` and
`bridge-host-subscriptions.ts` rather than retyped, the same way the
harness already reads the protocol version and the CSP, so a double
carrying a stale number is not possible. The render check's own
`640 * 1024` is gone with them.

One case for it: thirty frames of about 200 KB, roughly 6 MB through a
4 MiB window, nothing over the message cap, so a drop can only come from
the window. Every frame posts, nothing is dropped, and the page's ack
seqs are read back to show the window stayed open because the page acked
rather than because the double was generous.

The file docstring said the double answers no RPC. It serves a
screencast stream now, so it says that instead, and says what it still
is not: it decides no domain behaviour.

The dead `violations` array is gone, folded with the CSP commit.

Red first: make the `ack` arm inert, as it was before this commit, and
the case fails with `Set{'posted','dropped'}` against `Set{'posted'}`.
A first attempt at that mutation left the byte subtraction in place and
stayed green, which is the mutation being wrong rather than the case.

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

* ci(mobile): run the whole mobile-web-app family, not a list that goes stale

The `mobile_web_app` job hand-listed ten files. The render check this
chain added was not among them, so it would have skipped in CI — and it
was not the first: three landed censuses were already unlisted, and
their closure blocks only run with `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED=1`,
so they are green in the sharded `test` job whether or not they ever
ran here. Nobody could see it.

The list is now two vitest filename filters, `config/scripts/mobile-web-
app-` and the one builder test outside that prefix. Quoted, because
vitest matches a positional as a substring against the discovered files
rather than expanding a glob: `mobile-web-app-*.test.mjs` finds nothing,
and it fails by reporting no test files rather than by running fewer.
Both forms were tried before this one was written.

It runs 18 files and 205 cases, against 10 files before. With mobile
dependencies absent, 111 of those 205 skip, which is the measure of what
only this job runs. Per file, cases CI has never run:

  browser-pane-render            7 of 7   (this chain)
  source-control-external-links  9 of 9
  source-control-keyboard        6 of 6
  source-control-text-inputs     6 of 19  (C4.2)
  frame-budget-sweep             4 of 4   (this chain)
  route-manifest                 2 of 17

Two more files the filter adds run fully in the sharded job already and
change nothing here: `browser-pane-text-inputs` (C6.4 — it censuses a
hand-written closure and never bundles, so unlike the report it was not
skipping) and `external-link-seam`.

The sweep is renamed into the family for the same reason. As
`mobile-browser-frame-budget-sweep.test.ts` it matched neither the job's
filter nor `pr-code-change-scope.mjs`'s `config/scripts/mobile-web-app-`
prefix, so a change to it alone would not have run the job that runs it.
It is also gated on the dependency check now: it needs no
react-native-web, but it launches Chromium, and that flag is what tells
the job with a browser from the one without. Unguarded it would have
failed the sharded `test` job outright.

That filter is a prefix match. `config/scripts/mobile-web-app-` and
`mobile/src/` both fire this job, and `.github/workflows/pr.yml` is in
GLOBAL_FORCE_PREFIXES, so this commit runs everything.

Also: `postedFrame` in the ruling-2 pin and in the sweep both reached
the binary lane through `?.`, so a subscribe that opened no stream read
as zero posts — indistinguishable from a dropped frame, which is the
verdict both files are about. They throw now. The render check's
restated `640 * 1024` went with the window caps in c73b405f81; the cap
is read from `bridge-caps.ts`.

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

* ci(mobile): record what the mobile-web-app job costs to run

The filter that replaced the hand list runs 18 files where the list ran
10, so the step's cost is now a function of what anyone names into the
family rather than of what a reviewer remembered to add. Measured on
this machine: 25-30s wall for the whole step, of which the frame-budget
sweep is 2.5s.

The sweep is the one part whose cost is a choice. It encodes 111 noise
JPEGs in Chromium, one per viewport the budget fits, so adding rows to
that set is a decision about this job's runtime and the comment says so
where someone would make it.

Found, not fixed, and reported for its own PR rather than folded here:
the page bundles two copies of zod, mobile's 4.4.3 and the repo root's
4.5.4, reached through `src/shared/zod-salvage.ts`, which resolves
upward while `mobile/src/` resolves to mobile's. That is 808 KB of
duplicate source and two module instances in the shipped page. Aliasing
`zod` in `mobileWebAppBuildOptions` fixes it and was measured working
during this chain; it is reverted and stays reverted, because it changes
which zod shared code runs in the page and that is not a call to make
inside a CI commit. The CSP fix in 71254ab3a9 does not depend on it:
`globalConfig` lives on `globalThis`, so the banner covers both copies.

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

* fix(mobile): load the frame-budget sweep's mobile modules after the dependency guard

vite transforms every file under mobile/ against mobile/tsconfig.json, which extends
expo/tsconfig.base.json; the sharded test job installs no mobile dependencies, so the
sweep's static imports failed the file at load before describe.skip ran. Type-only imports
stay static; the values load in beforeAll behind mobileWebAppDependenciesPresent().

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

* test(mobile): certify the frame budget at the quality the pane ships

Round 2: the sweep and the render check encoded fixtures at a retyped 0.72; both now read
BROWSER_FRAME_QUALITY (the sweep from the module, the render check through the harness reader),
so a quality change fails the certification instead of leaving it green. Every render case now
asserts zero CSP violations; the sweep pins the 32 viewports left at scale 1; three references to
a renamed file and a file that never existed are corrected; a shim count comment is made
count-agnostic.

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

* test(mobile): assert the frame-budget sweep against the constant, not the measured maximum

The margin above the measured 0.55351 is what an encoder drift is allowed to spend; pinning the
measurement made a drift inside the margin fail a budget that still held. The number stays in the
docstring as the sweep's record.

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

* test(mobile): move the sweep's measured-maximum note beside the assertion it explains

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 07:43:14 -04:00
Jinwoo Hong 96c1dd8b70 feat(mobile): budget the browser pane's frames and put its inputs on the web seam (OTA phase C, C6.3+C6.4) (#21760)
* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2)

The pane's frame path is written against React Native's native-prop writer, which does
not exist on React Native Web: a ref there is the DOM node, so both writes throw and the
pane never shows a frame. Three `.web.ts` siblings, each for a measured gap.

- The image and layer writes move out of `mobile-browser-frame-state.ts` into
  `browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image`
  on the element RN Web sizes and flips the double buffer with one opacity write per
  layer. The pane still never re-renders while it streams.
- A `background-image` write fires no load event, so the offscreen layer would never
  become visible. The sibling arms the flip from an image decode instead, and the flip
  itself is shared with the native `onLoad` path rather than written twice.
- The data URI keeps the base64 the bridge already carried instead of encoding the bytes
  back into the same string. Measured in this tree against the `buffer` shim the page
  bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against
  under a microsecond for the carried string.

Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and
renders its existing stream-error state otherwise, so a page never waits on frames a shell
without the encoder cannot send. The grant name is a placeholder until C6.1 reports it.

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

* feat(mobile): budget the mobile view's frame area against the bridge cap (OTA phase C, C6.3)

A screencast frame crosses the bridge as one message under BRIDGE_MAX_MESSAGE_BYTES.
Measured here: the phone's mobile view at the native device scale factor asks for a
390x712 viewport at 2x, which is 1,110,720 device pixels, and at the worst case JPEG has
at quality 72 that is a 807,559-byte message against a 655,360-byte cap — 123% of it.

The `.web.ts` sibling holds the mobile view inside that. The budget is computed rather
than written down: the cap, less an envelope this measures from the frame's own shape at
its widest (435 bytes), is what the base64 may occupy; three quarters of that is the JPEG;
divided by one named worst-case constant of 0.545 bytes per pixel it is an area of 901,271
pixels. The phone lands on a device scale factor of 1.80 and a 654,205-byte message, 99.8%
of the cap. A cap that moved and a budget that did not would be a pane going dark on a
page it could have streamed.

Web view mode is untouched, and byte-identical to the native request: there the frame is a
desktop viewport letterboxed into maxWidth/maxHeight, which the page cannot predict, so C6
ruling 1's drop-the-over-cap-frame rule is its only protection. Native is unchanged.

The constants and the assembly move to a third module because a `.web.ts` cannot import a
value from the file it shadows — the bundler resolves the specifier back to the sibling
itself — and two copies of them would drift.

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

* feat(mobile): put the browser pane's text inputs on the web font-size seam (OTA phase C, C6.4)

The pane has two text inputs, the address bar at 12px and the key row's "Type on page…"
at 14px, and neither went through TEXT_INPUT_FONT_SIZE. In a browser an input under 16px
makes iOS zoom the page on focus and never zoom back, and keyboard-occlusion.web.ts reads
a visual viewport scale other than 1 as "no keyboard" — so one focus would leave the
pane's keyboard lift at 0 for the rest of the typing session. C4.2's failure exactly, on a
screen its census does not walk: that census walks the source-control hub and the review
route, and the pane is in neither until C7 lists a route that mounts it.

The key row's input goes straight onto the seam, whose native value is the theme's body
size, so it renders at the 14px it already did. The address bar is a `.web.ts` split
instead, so native keeps the 12px meta size it has always shown; the input and the label
painted over it move together, or the address would resize on every focus.

The address bar also gets inputMode="url" on the web only. keyboardType is a native enum a
browser does not read, so the page's address bar was falling back to a plain keyboard;
inputMode takes precedence over keyboardType, so it stays undefined on both native
platforms.

One consequence recorded rather than fixed, and pinned in
config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs: the C4.2 census resolves
an import through .ts/.tsx only, never .web.ts, so it reads the native address style that
no browser loads and reports it as an offender. Whoever lists the pane's route either
teaches resolveLocal the extensions the builder already prefers, or moves the address bar
onto the seam natively at 14px.

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

* fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2)

C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the
placeholder this PR landed with while C6.1 was still choosing.

The placeholder was also unusable, which the test added here would have caught:
`GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed
verb and nothing else, so a route declaring `browser.screencast.binary` would have been
refused by the bundle before any shell saw it, and the pane would have taken its
stream-error branch for a reason no screen could report. The name is now checked against
`MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted
spelling as the failing case beside it.

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

* fix(mobile): keep the address field test inside the typecheck ratchet (OTA phase C, C6.4)

`findByType('TextInput')` does not typecheck: a host-component string is not an
`ElementType`, so the file fell out of `tsc -p tsconfig.test.json` and the tests-typecheck
ratchet reported it. Found by reading the ratchet's exit code rather than its piped tail,
which is how it was missed the first time. The element is looked up by its placeholder
instead, and the ratchet is green with 732 test files in the program.

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

* fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2)

Round 1 folds on #21754.

The undecodable arm freed the pending slot without checking whose frame had failed, while
the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints
the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame
3, then frame 3 decodes and the flip is refused because the slot no longer names its
layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has
gone still sends no further frame to recover with. Web only; native never calls this.

Both arms now answer for the frame they were armed with.

The displayable arm's own guard had no test: deleting it left `src/browser/` and the full
suite green, because the case that exercised it settled both decodes and asserted an end
state both orders produce. The harness now settles one decode at a time, keyed on the
source it was given, and the ordered case reds without the guard.

Also: the paint sibling's opacity test claimed "no re-render" while asserting two style
strings, so it is named for what it checks and the claim is counted where React is —
across ten streamed frames the three state setters are called once each, on the mount
frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so
two pre-existing reasons keep their literal em dash instead of a re-serialized escape.

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

* test(mobile): find the address input by its imported type (OTA phase C, C6.4)

`findByType('TextInput')` does not typecheck — a host-component string is not an
`ElementType` — so the file dropped out of `tsc -p tsconfig.test.json` and the
tests-typecheck ratchet reported it. The imported component is what the element is looked
up by now; the react-native mock stands it up as that same string at runtime, so the
lookup is unchanged and the file is back inside the ratchet's program.

Why the earlier run reported 0: the command was
`node scripts/check-tests-typecheck-ratchet.mjs 2>&1 | tail -2; echo $?`, and `$?` after a
pipeline is the exit code of `tail`, which is always 0. The banner line that printed was
the last line of the failure banner, not the success one. Every gate in this branch's
report is now read from the command itself, unpiped.

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

* fix(scripts): read a text input's size from the file the page loads (OTA phase C, C6.4)

The census followed an import through `.ts`/`.tsx` only, while the closure it walks comes
from esbuild, which prefers `.web.tsx`/`.web.ts`. So a style module with a platform
sibling was judged on the half no browser loads. That fails in the direction that matters:
a split whose web half sits under the focus-zoom floor reads as clean because its native
half is on the seam, which is the exact shape the seam exists to catch.

`resolveLocal` now tries the extensions in the builder's own order. The seam comparison
collapses a resolved path onto its module identity before matching, because the seam is
itself a split — `text-input-font-size.web.ts` is where the raise lives — and without that
every binding in the tree would stop naming the seam: deleting it reds both C4 route
closures.

The browser pane's own census flips from pinning its address field as an offender to
expecting none. The two C4 route closures still answer 0 offenders and 0 unresolved, run
with the closure tests enabled.

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

* fix(mobile): bound the frame envelope above every double it can carry (OTA phase C, C6.4)

Round 1 folds on #21760.

The envelope estimate serialized each metadata field as a 16-character double, so the
bound was 435 where a real frame event at its widest measures 516. Since the budget spends
whatever the cap leaves, that 81-byte shortfall was the whole margin: a frame on the budget
would have gone over the cap and been dropped. The bound is now the skeleton plus the
widest a double can print, for the nine metadata keys imported from the protocol module
rather than copied, so a tenth field cannot be added without being paid for.

Two corrections to the fold as written, both measured here.

The widest is 25 characters, not 24. Exponential form tops out at 24
(`-1.7976931348623157e+308`), but ToString only leaves fixed notation below 1e-6, and just
above it a double prints as sign, `0.`, five zeros and seventeen digits:
`-0.0000012345678901234567`. A sweep over four million random bit patterns found 25 and
nothing longer; a deterministic sweep over both forms is in the test.

And the base64 expansion has to count padding. Three quarters of the room claims up to two
characters base64 does not have for an image of 3k+1 bytes, which at a margin the budget
now spends exactly is a dropped frame. The two agree at today's envelope size because the
room happens to divide by four, so this is a latent fix rather than a reproduced one, and
the test pins the discrepancy directly instead of implying it.

Budget moves from 901,271 to 901,161 pixels; the phone's mobile view stays at a device
scale factor of 1.80. Pinning the bound against C6.1's real encoder is C6.5's, once both
are on main; the docstring says so, and says what the bound does not cover: the metadata
object is loose, so unknown keys and web view mode's letterboxed frame are ruling 1's to
drop rather than this budget's to predict.

Also: the pane census listed its own closure by hand, so "no unresolved styles" said the
walk read those files, not that they are the pane's set. It now scans `src/browser` for
every non-test module that renders a `TextInput` and asserts the list matches; a third
module planted there reds it. And the seam's native-consumer pin names the key row and the
address bar directly rather than transitively, matched at the `fontSize` property instead
of anywhere in the file — a file-wide search survives the change, because the import line
does.

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

* test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2)

Round 2 folds on #21754.

Every test for the web paint sibling handed it a `div > div` of its own making, so the
assumption it rests on — that the host's first element child is the one carrying the
frame — was only ever checked against a shape written to match it. React Native Web also
renders an accessibility `<img>` in there, and a release that reorders those children
would keep all of them green while the pane painted nothing.

One test now renders the real component, asks it which child it painted, and checks the
write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the
hand-built cases passing, which is the gap. A second case records what the `<img>` does:
the streaming path writes styles and never props, so it keeps the source it mounted with
for the life of the pane, and that is what a screen reader and the image context menu see.

react-native-web ships no type declarations, so the component comes through
`createRequire`, whose return is `any` at its own signature; the one prop it renders with
is declared rather than asserted, and the file stays inside the tests-typecheck ratchet.

The module docstring also claimed more than the code does. The frame path adds no render,
but a render from any of the pane's other state — address focus, a dialog, the view mode,
zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`,
so both land on the newest frame whether or not it has decoded. Native clobbers the same
way through `setNativeProps`. Said plainly, along with what restores the buffering.

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

* docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2)

pullfrog is right, and the test carried the same wrong claim. The note said the hidden
`<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after
saying a render from the pane's other state passes `renderedFrameSource` as `source` —
and React Native Web derives that image's `src` from the same prop it paints the
background from, so the first such render moves it.

Measured here rather than reasoned about: rendering the real component, writing a frame
imperatively, then re-rendering with a new source moves the `src` and leaves the
background where the imperative write put it. The two halves are now two cases, named for
what each one shows, and the note says the streaming writes never touch it while a render
does — so it holds the frame the pane last rendered with, not the one on screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 05:13:10 -04:00
Jinwoo Hong 4e9d5b577e test(mobile): pin the decoded screencast frame with the base64 it now carries (OTA phase C, C6) (#21769)
C6.1 (#21758) pinned the frame `decodeBridgeScreencastFrame` hands back with an exact
`toEqual`; C6.2 (#21754) made that decoder carry the wire's `b64` on the frame so the page's
data URI can reuse it. Each PR was green against the main it branched from, and their
squashes together red two of C6.1's cases on main. The pins stay exact and gain the field,
with the encoded string spelled out rather than wildcarded.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:50:50 -04:00
Jinwoo Hong 6f0fb3fe39 feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2) (#21754)
* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2)

The pane's frame path is written against React Native's native-prop writer, which does
not exist on React Native Web: a ref there is the DOM node, so both writes throw and the
pane never shows a frame. Three `.web.ts` siblings, each for a measured gap.

- The image and layer writes move out of `mobile-browser-frame-state.ts` into
  `browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image`
  on the element RN Web sizes and flips the double buffer with one opacity write per
  layer. The pane still never re-renders while it streams.
- A `background-image` write fires no load event, so the offscreen layer would never
  become visible. The sibling arms the flip from an image decode instead, and the flip
  itself is shared with the native `onLoad` path rather than written twice.
- The data URI keeps the base64 the bridge already carried instead of encoding the bytes
  back into the same string. Measured in this tree against the `buffer` shim the page
  bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against
  under a microsecond for the carried string.

Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and
renders its existing stream-error state otherwise, so a page never waits on frames a shell
without the encoder cannot send. The grant name is a placeholder until C6.1 reports it.

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

* fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2)

C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the
placeholder this PR landed with while C6.1 was still choosing.

The placeholder was also unusable, which the test added here would have caught:
`GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed
verb and nothing else, so a route declaring `browser.screencast.binary` would have been
refused by the bundle before any shell saw it, and the pane would have taken its
stream-error branch for a reason no screen could report. The name is now checked against
`MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted
spelling as the failing case beside it.

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

* fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2)

Round 1 folds on #21754.

The undecodable arm freed the pending slot without checking whose frame had failed, while
the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints
the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame
3, then frame 3 decodes and the flip is refused because the slot no longer names its
layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has
gone still sends no further frame to recover with. Web only; native never calls this.

Both arms now answer for the frame they were armed with.

The displayable arm's own guard had no test: deleting it left `src/browser/` and the full
suite green, because the case that exercised it settled both decodes and asserted an end
state both orders produce. The harness now settles one decode at a time, keyed on the
source it was given, and the ordered case reds without the guard.

Also: the paint sibling's opacity test claimed "no re-render" while asserting two style
strings, so it is named for what it checks and the claim is counted where React is —
across ten streamed frames the three state setters are called once each, on the mount
frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so
two pre-existing reasons keep their literal em dash instead of a re-serialized escape.

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

* test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2)

Round 2 folds on #21754.

Every test for the web paint sibling handed it a `div > div` of its own making, so the
assumption it rests on — that the host's first element child is the one carrying the
frame — was only ever checked against a shape written to match it. React Native Web also
renders an accessibility `<img>` in there, and a release that reorders those children
would keep all of them green while the pane painted nothing.

One test now renders the real component, asks it which child it painted, and checks the
write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the
hand-built cases passing, which is the gap. A second case records what the `<img>` does:
the streaming path writes styles and never props, so it keeps the source it mounted with
for the life of the pane, and that is what a screen reader and the image context menu see.

react-native-web ships no type declarations, so the component comes through
`createRequire`, whose return is `any` at its own signature; the one prop it renders with
is declared rather than asserted, and the file stays inside the tests-typecheck ratchet.

The module docstring also claimed more than the code does. The frame path adds no render,
but a render from any of the pane's other state — address focus, a dialog, the view mode,
zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`,
so both land on the newest frame whether or not it has decoded. Native clobbers the same
way through `setNativeProps`. Said plainly, along with what restores the buffering.

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

* docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2)

pullfrog is right, and the test carried the same wrong claim. The note said the hidden
`<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after
saying a render from the pane's other state passes `renderedFrameSource` as `source` —
and React Native Web derives that image's `src` from the same prop it paints the
background from, so the first such render moves it.

Measured here rather than reasoned about: rendering the real component, writing a frame
imperatively, then re-rendering with a new source moves the `src` and leaves the
background where the imperative write put it. The two halves are now two cases, named for
what each one shows, and the note says the streaming writes never touch it while a render
does — so it holds the frame the pane last rendered with, not the one on screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:30:15 -04:00
Jinwoo Hong f5d2d6e757 feat(mobile): carry browser screencast frames over the bridge as base64 (OTA phase C, C6.1) (#21758)
* feat(mobile): carry screencast frames over the bridge as base64 (OTA phase C, C6.1)

`bridge-screencast-binary.ts` landed in C0 as the page's half of the binary
lane and named C6 as the owner of the encoder that satisfies it. This is that
encoder, plus the host honouring `wantsBinary`: a subscribe that asked for
binary gets an `onBinaryFrame` on the native stream, and each frame crosses as
the envelope's `event.binary` on the same `seq` ledger as the stream's JSON
events, because the page acks by that count.

The base64 encoder is grouped rather than per byte or per `fromCharCode`
window. Its docstring carries the measurement, including the part that
contradicts the design note this came from: on V8 the per-byte form is the
fastest of the three, not the quadratic one, and the chunked form it was meant
to beat is the slowest. The grouped one is here because its cost does not
depend on how an engine ropes `+=`, and Hermes is what the shell runs.

No new opcode, no `v` bump, no negotiation added: `wantsBinary` is already in
the contract and is the negotiation. Over-cap behaviour is unchanged in this
commit — a binary event over the frame cap still ends the stream, which is what
C6.2 changes.

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

* feat(mobile): drop an over-cap screencast frame instead of ending the stream (OTA phase C, C6.1)

Measured at the pane's own request parameters, a screencast frame exceeds the
640 KiB envelope on a phone layout whenever the page will not compress: JPEG's
worst case is 0.545 bytes per pixel at quality 72, so mobile view mode at
780x1424 is 811,289 bytes, 124% of the cap. Ending the stream there blacks out
a browser tab for the life of the pane over one frame.

So the two kinds of event part at the cap. A JSON event that will not fit still
ends the stream with `overflow`, because its reader cannot see the hole it
would leave; a screencast frame is dropped and the stream lives, because the
next frame is one throttle interval away and the pane is still showing the last
one. Both are asserted side by side so neither turns into the other.

A drop leaves no other trace: the diagnostic beside it prints once per host, so
a stream shedding a frame a second and one that shed a single frame read the
same. The host therefore counts them per stream for the diagnostic and keeps a
session total, and the shell's dev facts carry that total — the surface that
already shows build state, with the line moved into its own module so what it
says is pinned rather than inferred from a template. The 12-character build
prefix it has always shown is unchanged.

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

* feat(mobile): name the binary screencast lane as a grant (OTA phase C, C6.1)

Ruling 5's negotiation, and the check it asked for first: no reader of a grant
is a closed enum, so there is no blocker and nothing an older page has to
tolerate. `BridgeGrantsSchema.native` and the shell's manifest reader are both
open string arrays, and the shell reader's own docstring already states the
degradation — a grant name a build does not know leaves that one route native
rather than refusing the bundle.

What does constrain the name is the host contract's `GRANT_NAME_PATTERN`: a
grant is one camelCase token or a `native.<domain>.<action>` verb with at least
two dot segments. So `browser.screencast` and `native.screencast` are both
refused, and the lane is `screencastBinary`. `screencast` alone would be wrong:
the page can already subscribe to `browser.screencast` and receive its JSON
events, and only the binary frames need the encoder.

Added to the shell's implemented set, which is the same list `init.grants.native
` offers, so a route declaring it is served by a shell that has the encoder and
left native by one that does not. No route declares it here; C7's session route
does.

The contract-side case is a characterisation pin, not a red-first one: the
pattern already admitted this name, and the test records that the two tempting
spellings are the ones it refuses.

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

* test(mobile): check the dropped-frame total through the bridge hook (OTA phase C, C6.1)

The hook gained a required `onBinaryFramesDropped` two commits ago and this
test kept calling it without one, so the tests-typecheck ratchet went red on
that commit — caught here rather than in CI because an exit code was read off a
pipeline's last stage instead of the script.

Fixed by wiring the callback into the probe rather than by a cast, and with the
case that makes the wiring evidence instead of types: a dropped frame raises
the total the screen receives, and the stream stays subscribed while it does.

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

* refactor(mobile): keep the dropped-frame counter with the ledger it belongs to (OTA phase C, C6.1)

Declared between a getter and a method, which is not where this class keeps
state: the subscription map is at the top and the counter is the same kind of
thing. Move only.

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

* fix(mobile): serve the binary screencast lane only to a route granted it (OTA phase C, C6.1)

Reported as a gap after C6.1's third commit and ruled on: the host honoured
`wantsBinary` from any page, so a route that never declared `screencastBinary`
could still make the shell encode base64 on its behalf. That is the hole
per-route grants exist to close — the same class as a route granted only
`navigate` and `storage` reaching the clipboard.

The rule now reads the session's resolved list, which is what its route
declared narrowed to what this shell implements, and is the same set
`init.grants.native` is built from. So the host offers the lane in `init`
exactly when it will serve it.

Ungranted is not a refusal. The subscription proceeds and its JSON events cross
as before, which is the silence every other grant gives at the call site; a
page that reads its own grants never reaches that state. Both branches are
pinned beside each other, and `grantsForRoute` is pinned dropping a grant this
shell does not implement — granted-but-unimplemented and never-granted arrive
at the host as the same absence, so its rule reads one case.

The grant name moves into the module that holds the rule reading it, so the two
cannot drift. `bridge-host.ts` is at 298 of its 300-line cap after this.

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

* refactor(mobile): move the page's stream-frame rules out of the host (OTA phase C, C6.1)

`bridge-host.ts` reached 298 of its 300-line cap, so the next main merge that
touched it would have crossed under CI pressure on someone else's PR. Split
deliberately instead, at the boundary the growth came from.

`bridge-host.ts` is the host's lifecycle and its dispatch. Opening a stream is
the only frame kind whose handling is more than one line of delegation — four
refusals and, since C6.1, the binary-lane decision — so it moves whole, and
`cancel` and `ack` move with it so all three stream frames are decided in one
place. The host's `cancel` arm still chooses between a stream and a request
where it always did: a page's `cancel` names one or the other, and splitting
that choice would leave half an arm in each module.

Counted without blank lines or comments, as the rule counts them:
bridge-host.ts 298 -> 270, and the new module is 59.

A pure move. No test changed and none was added, which is what makes the
existing suites the proof: 45 files and 745 tests green on the same assertions
as before.

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

* feat(mobile): report a page that asked for screencast frames it was not granted (OTA phase C, C6.1)

An ungranted `wantsBinary` is not a refusal on the wire, so nothing crosses
back: the subscription proceeds and its JSON events cross as they always have.
That left a page which did ask getting JSON for the life of the document with
no side able to say why. `notify-refused` has covered the equivalent notify
case since C0; this is the same shape for the one frame kind that lacked it.

The rule now answers a verdict rather than a boolean, because `not-asked` and
`ungranted` are the same answer for different reasons and only one is worth
reporting. So the decision and the report read one rule, and a page that never
asked stays silent — pinned, along with a granted route staying silent, so the
line cannot start firing on either.

The wire is unchanged and pinned unchanged: the case beside this one still
asserts one JSON event delivered and zero error frames.

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

* fix(mobile): reset the dropped-frame total with the host that counts it (OTA phase C, C6.1)

Round 1 on #21758, three findings.

The real one: the count is per host and the screen's copy was not. A rebuilt
host starts its own total at zero, so the screen kept the retired host's number
until the new one dropped a frame and then read *lower* — a falling count looks
like frames coming back, which is worse than starting over. The hook now
announces a fresh count as it builds a host. That also reports zero on the
first build, where the screen is already at zero and React bails out of the
render; the two hook cases pin that leading zero rather than leave it to be
rediscovered.

Two docstrings that described nothing: `BUILD_ID_PREFIX_LENGTH`'s stayed behind
when the constant moved to the dev-facts module and had drifted above
`failureMessage`, and `page-route-policy.test.ts` kept the docstring of the
test it replaced above the one that replaced it. Both deleted; the first's text
lives on the new module.

Red-first for the reset, checked against its final expectations rather than its
first: with the one line reverted both hook cases fail on the missing zero, and
both pass with it.

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

* fix(mobile): keep the dropped-frame total out of a production build's render path (OTA phase C, C6.1)

CodeRabbit's Major on #21758. The total went into React state on every dropped
frame in every build, and outside a development build the line that reads it
renders null — so an over-cap page re-rendered the whole shell screen up to ten
times a second for a fact nobody can see. Measured, not argued: five drops,
five extra renders.

Fixed at the seam rather than with a ternary at the call site. The dev-facts
module owns the line, so it now owns the number behind it and the rule that the
number is only state where something renders it. The screen holds no flag and
no counter; it asks for both and passes the reporter on. The reporter is stable,
so the bridge host is never rebuilt for it.

`isDevelopmentBuild` becomes a call rather than a module constant. A build flag
never changes at runtime so this costs nothing, and as a constant the branch was
unreachable to anything that did not set the global before the module loaded —
which is why the production case could not be written at the screen at all.

Also fixed, found while writing that case: the screen test's
`usePageHostSnapshot` double returned a fresh object on every render, so the
host effect's identity changed each time and the bridge host was torn down and
rebuilt on every render of the screen, settling every pending request with it.
The real hook holds the snapshot in `useState` and is stable. One object for the
file now. This was masking the fold under test — the count reset to zero on
every render — and every other case in that file was measuring a rebuild storm.

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

* perf(mobile): price a screencast frame before encoding it (OTA phase C, C6.1)

Round 2 on #21758, two lows.

The encode is a base64 pass over the whole image and the window decides whether
the frame can be posted at all, so deciding after encoding made a page that had
stopped acking pay for every frame the shell then threw away — the reviewer's
case is ten 300 KB frames against a closed window, 3 MB encoded and nothing
sent. The size is knowable without encoding: base64 is ASCII, so JSON escapes
none of it and the frame is its header serialized plus exactly the image's
encoded length. `encodeBridgeScreencastFrame` is now built from that header
rather than beside it, so the shape measured and the shape sent cannot drift,
and the window arithmetic is one rule read before the encode and again on the
frame that was.

Exact, not conservative, so the drop diagnostic still reports the whole frame
and the committed byte pin is untouched.

Red-first with the real encoder wrapped in a counter: window full, ten frames,
ten encodes before and zero after, with the drop count still ten. An over-cap
frame likewise goes from one encode to none. A third case holds the other
direction — two carryable frames still encode twice — so the fix cannot pass by
encoding nothing.

Second low: the dev-facts block sat outside the only `beforeEach` and left
`routeGrants` and `client` mutated, inert only because it runs last. The shared
setup moves to file level where the mutable dependencies actually live, resets
both, and a case at the end of the file pins it — deleting the reset fails
there and nowhere else, since nothing else runs after a case that mutates them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:19:00 -04:00
8dee68a8d1 fix(terminal): preserve Polish and Option-composed text in kitty panes (#21082)
* fix(terminal): preserve Option-composed text in kitty panes

Adapt the composition fix from #20579 and the input-source correction from
#20164. Extend coverage to every Polish letter, live setting changes,
associated text, and Chromium-to-PTY word entry.

Co-authored-by: yu.xia <yuxianice@163.com>
Co-authored-by: Alexandre Blause <alexandre.blause@gmail.com>

* test: guard native Korean IME against background launch

---------

Co-authored-by: yu.xia <yuxianice@163.com>
Co-authored-by: Alexandre Blause <alexandre.blause@gmail.com>
2026-09-20 00:28:25 -07:00
Neil fa4ea57871 fix(terminal): keep Pi input visible in open synchronized frames (#21708)
* fix(terminal): keep Pi input visible in open synchronized frames

* test(terminal): keep synchronized input fixture lint-clean

Place the existing SAFETY lint directive directly on the private xterm state assertion so the repository quality gate recognizes the reviewed test-only cast.

* fix(terminal): preserve startup parse callback

* fix(terminal): bound frame close after safety flush

* test(terminal): type startup callback fixture
2026-09-20 00:13:33 -07:00
Jinwoo HongandClaude ee61e3bd41 fix(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2) (#21735)
* feat(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2)

react-native-web's `Keyboard` is a stub: `addListener` returns a
subscription that never fires and `isVisible()` is always false. A screen
inside the shell's page that waits for `keyboardDidShow` waits for the
life of the document, and the software keyboard covers whatever sits at
the bottom of it. Two C4 screens are text entry at the bottom.

`platform/keyboard-occlusion` is the pair. The native file carries the
source-control hook's logic unchanged, events and clamp and the comment
that travels with it. The web sibling reads `visualViewport`: the layout
viewport keeps its size and the visual one shrinks, so the occluded strip
is `innerHeight - (height + offsetTop)`. `offsetTop` is in it because a
scrolled or pinched visual viewport sits partway down the layout viewport
and the strip below it is not keyboard; dropping the term reds two cases.
It listens on `resize` and `scroll` — the browser scrolling a focused
input into view moves the offset without resizing anything — and reads
once at mount, because a composer opened over an already-raised keyboard
receives no event at all; dropping that read reds a third case.

`useKeyboardAvoidingPadding` is a second name rather than a `Platform.OS`
branch at the call site. Natively it is 0 and subscribes to nothing, so a
composer that asks for it renders exactly as often as it does today;
`KeyboardAvoidingView` has already moved it and padding would move it
twice. On the web it is the whole of the avoidance, that view being
driven by the events this file exists because the page never receives.

No `visualViewport` answers 0 rather than guessing.

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

* fix(mobile): lift the commit bar and the note composer inside the page (OTA phase C, C4.2)

The two consumers move onto the seam. The hub's hook becomes one line and
keeps its name, which is what the hub's state calls the number. The note
composer takes the padding as a style on the `KeyboardAvoidingView` it
already had: natively that is 0, so the prop is `undefined` and the phone
renders exactly what it rendered before; inside the page it is the strip
the keyboard covers, which is the only thing that moves the composer
there.

The census is over both future route closures rather than over the two
call sites: `platform/keyboard-occlusion` is the one module in either
closure allowed to name the stub. Red first at the base commit — run in a
throwaway worktree at `9309350864` rather than by setting the fix aside —
it named `use-mobile-source-control-keyboard-lift.ts` as a subscriber
outside the seam and found the seam's web file in neither closure.

`mounted-bottom-drawer.tsx` is exempt by name, and the census asserts the
exemption is really in both closures so it cannot outlive its subject. It
reads more than a height — `Keyboard.metrics()` for a sheet opened over a
raised keyboard, and each event's `duration` to animate with it — which
the seam does not model, and it sits in C1's, C2's, C3's and C5's closures
too, so moving it is a change to every page rather than to this domain.
Its listeners are inert on the web the same way, which is why the composer
inside it takes its own padding rather than inheriting one.

No render-check case: measured, none of the five registered routes reaches
the seam, the commit bar or the composer, and a headless browser cannot
shrink the visual viewport independently of the layout one anyway. C4.4
carries it.

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

* test(mobile): type the keyboard harness instead of asserting its fields (OTA phase C, C4.2)

The changed-code gate flagged the two `as` casts in the hoisted harness.
A return type on the `vi.hoisted` callback says the same thing and is
checked rather than asserted, which is the shape the host-list route test
already uses.

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

* fix(mobile): read a pinch zoom as no keyboard, and test the clamp (OTA phase C, C4.2 round 1)

Round-1 folds plus CodeRabbit's exemption point.

**A pinch zoom read as a keyboard.** A 2x zoom shrinks the visual viewport
by exactly as much as a half-screen keyboard, so the commit bar and the
composer moved on a page nobody was typing into. A `scale` other than 1
answers 0. Geometry alone cannot tell the two apart and a stored "no
keyboard" baseline would be a heuristic, so a keyboard raised while zoomed
is the accepted rare case rather than a guess. `scale` is read defensively
because older WebViews do not implement it, and taking its absence for
zoomed would answer 0 for every keyboard on them; mutating the guard to
key on absence reds both cases.

**The clamp had no test.** A bare subtraction left all nine cases green.
The case is a visual viewport taller than the layout one, which mobile
Safari reports mid-scroll and which would have pushed the commit bar down
the screen instead of up.

**One guard, where the test reaches it.** `occlusion`'s `viewport ===
undefined` arm was unreachable: the effect returns before calling it, and
the absence case exercised that one. Deleted, and the remaining case says
which guard it proves.

**The census exempts two files, not a directory.** `startsWith('src/platform/')`
would wave through a later `src/platform/*.web.ts` that subscribed to the
stub directly, which is the defect this census exists for. Named exactly,
with a planted subscriber beside the seam as the fixture; restoring the
directory filter reds it.

**And the moved comment claimed an inset it never subtracted.** Deleted.
Correcting a comment that was false where it came from is not a rewrite of
the logic the move carried: no statement moved with it.

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

* fix(mobile): keep the page at scale 1 so the zoom guard is not the keyboard path (OTA phase C, C4.2 round 2)

Round 2's finding changes what the zoom guard costs. iOS auto-zooms on
focus of any input under 16px; both consumers' inputs are 14px
(`typography.bodySize`), and the page's viewport meta set no
`maximum-scale`. So `scale !== 1` was not the rare pinch the guard was
written for, it was every focus — and the seam would have answered 0 on
the one flow it exists for.

The guard stays and the premise is fixed instead: `maximum-scale=1` in
both places the page's meta is written, the built document in
`build-mobile-web-app-bundle.mjs` and the bootstrap `index.html`. iOS
honours it for the focus auto-zoom and has ignored `user-scalable=no`
since 10, so a deliberate pinch still works; the input sizes are
untouched. C4.6 step i is what settles it on a device.

Three test changes and one correction.

The census took a `rootDir`, as `findWebSiblings` does: it planted
`src/platform/other.web.ts` in the real tree while the overrides census
walks `mobile/src` in a parallel worker and would read it as an unlisted
override. It plants under `mkdtemp` now, and writes the two seam files
there too, so the empty result for them is the name exemption working
rather than those files happening not to subscribe.

A case for the ruling itself: scale 2 with a viewport shrunk past what
the zoom explains answers 0. Dropping the guard reds it and the pinch
case together.

`useKeyboardAvoidingPadding` is rendered through the test renderer now
instead of called outside one, with a counter on `Keyboard.addListener`.
Making the native hook return `useKeyboardOcclusion()` reds it at two
calls; the old shape could not see that, because a hook read outside a
component never runs its effects.

Item 4 did not hold as written. `window.visualViewport ?? undefined` is
not a no-op: the DOM declares the property `VisualViewport | null` and an
older WebView omits it entirely, so the coalesce was normalising both
shapes into one `=== undefined` check. Removing it and testing only for
`null` throws on the absent-viewport case (reproduced: `Cannot read
properties of undefined (reading 'scale')`). The coalesce is gone and the
guard names both shapes instead.

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

* fix(mobile): raise the two page inputs to 16px on web instead of pinning the page scale (OTA phase C, C4.2 round 2)

`maximum-scale=1` is reverted from both metas. It fixed the right problem
in the wrong place: Android WebView honours it and iOS ignores it for
pinch, so the cost of stopping an iOS focus auto-zoom was deliberate
zoom on Android, taken from the users who need it most.

The font size is where it belongs. `src/platform/text-input-font-size.ts`
is the app's body size and `.web.ts` is that raised to 16, the size below
which iOS zooms on focus and does not zoom back. The commit bar and the
review note composer take their `fontSize` from it. A phone renders what
it rendered before: the native constant is `typography.bodySize`, so both
style objects are unchanged there.

`Math.max` rather than the literal, so a theme that raises the body size
past 16 keeps its own value.

The zoom guard stays and its rationale is rewritten to say what now keeps
the ordinary path off it: the inputs clear the floor, so a scale other
than 1 means a user pinched rather than an input took focus.

The pin is a unit case because the render check has no route to open yet.
Three assertions and what reds each: the web constant below 16 reds the
first, and a style going back to `typography.bodySize` reds the third,
which reads the two stylesheets as source because a node test resolves
the native sibling and would otherwise pass while shipping 14px to the
web. The overrides census covers the swap itself.

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

* fix(mobile): put every text input in the two closures on the size seam (OTA phase C, C4.2 round 2)

The 16px floor reached two inputs and the rationale claimed a page. Eight
more text inputs in the same two closures still declared 14px, so a focus
on any of them zoomed the document and the occlusion seam — which reads a
scale other than 1 as no keyboard — stopped lifting for the rest of that
session. "A scale other than 1 means a pinch" was false while they were
there.

All eight go through `TEXT_INPUT_FONT_SIZE`, named by the census before
the change:

  src/components/MobileSearchField.tsx:175
  src/components/SmartWorkspaceAdvancedFields.tsx:84
  src/components/SmartWorkspaceSourceField.tsx:137
  src/components/new-worktree-form-styles.ts:125
  src/components/pr-sidebar/MobileLinkPrForm.tsx:120
  src/components/pr-sidebar/mobile-pr-sidebar-styles.ts:299
  src/components/pr-sidebar/pr-comment-composer-styles.ts:20
  src/components/smart-workspace-source-drawer-styles.ts:60

Every one declared `typography.bodySize`, so there was no input carrying
a size of its own to preserve and the phone is byte-identical again. Each
of those style keys was checked for consumers first: all of them are read
by a `TextInput` and nothing else, so raising the web value moves no
other element.

The census is the rule rather than the list. Over both closures it
resolves each `TextInput`'s style to the module that really declares the
size — following a spread, because both seam-served inputs are reached
through `{ ...base, ...list }` and a walk that stopped at the first
module would have called their offence absent — and names anything not on
the seam as `path:line`. A style with no `fontSize` inherits and is not
an offender. Presence precondition: the seam's web file is in the
closure, so an empty list cannot mean a page with no inputs.

Run against the previous head it prints exactly those eight for both
routes; three fixtures under mkdtemp cover the cross-module line, the
spread, and the two non-offender shapes.

The web test's rationale named `maximum-scale=1`, which is gone; it names
the input floor now.

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

* test(mobile): make the input census prove its own enumeration (OTA phase C, C4.2 round 2 addendum)

The offender list only says every text input is on the seam if every text
input was read, and the walk could not tell "this key sets no size" from
"I could not follow this style" — both answered nothing, so a resolution
failure would have read as a clean input and the rule would have gone
quietly vacuous.

`resolveStyleKey` answers three ways now: not found, found with no size,
found with one. `unresolvedTextInputStyles` reports the first as
`path:line (key)`, and the census asserts it is empty for both closures
beside asserting the offender list is.

Measured rather than assumed, which is what the addendum asks for. The
two closures hold 12 `TextInput` elements and 13 style references; none
uses an inline style object and none is without a style prop. All 13
resolve, 12 to `TEXT_INPUT_FONT_SIZE` and one — `styles.disabled`,
combined with `styles.input` on the same input — to a style that really
sets no size. The reviewer picker is in that list at
`mobile-pr-sidebar-styles.ts:300`; it was already on the seam from the
previous commit, which enumerated from the closure rather than from the
review.

A fourth fixture plants both shapes side by side: a style with no size,
which is not an offender, and a style reached through a package import,
which is named.

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

* test(mobile): close three holes in the text-input census (OTA phase C, C4.2 fold 3)

All three of CodeRabbit's findings are on the completeness property the
addendum bought, and all three reproduced before the change: each shape
below answered 0 offenders and 0 unresolved, which is to say it vanished.

Inline style literals. The walk recorded only `object.key` references, so
`style={{ fontSize: 14 }}` was neither an offender nor a hole. Style
props are flattened structurally now — arrays, spreads, `?:`, `&&` and
parentheses down to the expressions that can really land — rather than
walked as a subtree, which had the second bug of descending into an
inline literal's own properties. `&&` is followed because
`[styles.input, disabled && styles.disabled]` is the shape this tree
actually uses; `null`, `undefined` and `false` branches contribute no
style and are dropped rather than called unfollowable. An inline literal
resolves in place, and any other shape — a call, a bare identifier —
lands in the unresolved list.

Source-order precedence. `{ input: safe, ...legacy }` is `legacy.input`
at runtime, and answering direct keys before spreads read `safe` and
called the override clean. Properties are walked in reverse source order
now, direct keys and spreads in one pass, first answer wins.

The seam by binding. `size.text !== SEAM_EXPORT` accepted anything
spelled `TEXT_INPUT_FONT_SIZE`, so a local `const TEXT_INPUT_FONT_SIZE =
14` two lines up passed, and so did an import of that name from any other
module — the regression the seam exists to stop, wearing its name. The
identifier is resolved in the declaring module and accepted only as an
import from `src/platform/text-input-font-size`.

That last one changes what a fixture must say: the existing seam case
spelled the name without importing it, so it plants the seam module and
imports from it now. Six new fixtures, all six red on the previous walk.

Re-measured at this head, both closures: 12 `TextInput` elements, 13
style references, 12 on the seam, 1 sizeless (`styles.disabled`, combined
with `styles.input` on one element), 0 offenders, 0 unresolved.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-20 01:29:30 -04:00
OrcaWinandm4air e225b4b7eb Fix stale Codex usage after reset (#21748)
* fix(rate-limits): refresh Codex usage after reset

* fix(rate-limits): converge weekly Codex reset usage

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-19 22:22:16 -07:00
Neil b5b727bddb feat(composer): restore compact branch picker UX (#21741)
* feat(composer): restore compact branch picker UX

* fix(composer): address picker review feedback
2026-09-19 19:05:14 -07:00
Jinwoo HongandClaude f3bda1bf3e refactor(mobile): seam moves and the shared shell route guard for the source-control domain (OTA phase C, C4.1) (#21732)
* refactor(mobile): open PR sidebar URLs through the external-link seam (OTA phase C, C4.1)

The three openers in the PR sidebar called `Linking.openURL` directly:
a check's "open on the web", a comment's permalink, and a link inside
comment Markdown. Inside the shell's WebView react-native-web routes that
to `window.open(url, '_blank', 'noopener')`, which both shells refuse and
which resolves anyway, so the tap reports success and opens nothing. Both
C4 routes reach the sidebar, so both would have shipped that.

The census is the point rather than the three edits. It derives the two
future route closures through `mobileWebAppRouteClosure` and holds every
module in them to the seam, so a module entering either closure later is
ruled without anyone adding it here. Red first it named all three by
`path:line`: CommentMarkdown.tsx:2, PRChecksSection.tsx:2,
PRCommentCard.tsx:2, on both routes.

The walk it runs was the third copy of one function, so it moves into the
seam's own module beside the predicate that module exists to share, and
the files and tasks censuses now call it too. It reports `path:line` where
the copies reported paths; `reachesReactNativeLinking` keeps its name and
its meaning and is now derived from the line list, so there is one rule.
An empty offender list is empty in either shape, which is why repointing
the two landed censuses moves nothing they assert.

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

* refactor(mobile): copy through the platform clipboard seam in review and conflicts (OTA phase C, C4.1)

The two copy actions both C4 routes reach called `expo-clipboard`
directly: the conflict section's refresh commands and the review sheet's
notes. On the web that module is `navigator.clipboard`, which needs a
secure context — the iOS shell serves the page from a custom scheme and
Android from https, so the path works on one platform and silently not on
the other. `useClipboardWriter` is the seam C2.4 landed for exactly that.

Red first, the census named both routes: `ExpoClipboard.web.js` in each
closure, and `src/platform/clipboard.web.ts` in neither.

Both call sites also stopped ignoring whether the pasteboard took the
text. The conflict section already returned on a throw, so the seam's
rejection reaches an arm it had. `copyNotes` had none and its only caller
is `void controller.copyNotes()`, so a rejection would have been unhandled
with "Review notes copied" left on screen; it now catches and reports
through the screen's own error line. That is the one behaviour change here
and the reason `clipboard` joins its dependency array.

Its suite mocked `setStringAsync` as resolving `undefined`, which the seam
reads as a pasteboard that refused, so every copy would have gone down the
new refusal arm unseen. The mock now resolves `true` and two cases pin
both arms; mutating the catch away kills the refusal one.

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

* refactor(mobile): take the source-control router from the handoff seam (OTA phase C, C4.1)

The hub takes its router once, in the openers hook, and passes it down to
the runners and the panel — so one `useRouter()` is this domain's whole
reach into routing, and it was expo-router's own. Inside the shell's page
that posts no `navigate`, so the hub's push to review would stay in the
document whatever its grants, and its push to a native route would paint
Unmatched over the page. Inert today: no C4 route is registered yet.

`use-mobile-source-control-runners.ts` is the second case and the reason
the rule reads value imports rather than identifiers: it named expo-router
only to write `ReturnType<typeof useRouter>`, a value import in a type
position that keeps the module in the graph. `RouteHandoff` is the seam's
own name for that type.

The census is C3.1's, and its walk moves to `src/navigation` rather than
being copied a second time; each domain keeps only its own evidence, the
list of modules meant to hold a router. Red first it named both modules on
the expo-router rule and reported no handoff caller at all.

The C2.9 hop census is unchanged and cannot move: its targets come from
the call sites, and the derivation over this tree returns the same ten
targets and the same 26 unresolved sites before and after this commit,
byte for byte. Its `HANDED_OFF` pin is over registered routes, of which
this adds none.

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

* refactor(mobile): move the shell route guard out of the files domain (OTA phase C, C4.1)

`src/files/mobile-file-shell-route.ts` was never about files: it parses a
route against `BridgeInitRouteSchema` and builds the key a shell screen
remounts on. It moves to `src/mobile-web-shell/shell-screen-route.ts` as
`shellScreenRoute` / `shellScreenRouteKey`, with its test. The move is
pure — with the rename applied and comments stripped, the old file and the
new one diff to nothing.

Three routes had grown their own copy of the call and two had none. The
copies go: `agent-history` and `tasks` now ask the shared predicate, which
is the same schema and the same fallback they already had. `index.tsx` had
no guard at all, so a `.` or `..` host id was handed over and came back as
"Update Orca to open this workspace" painted over the native list behind
the switch; it now stays native. That is the one behaviour change here,
pinned red first and killed by mutation.

`web.tsx` keeps handing that route over on purpose and is exempt by name:
its fallback is a redirect to the route the user came from, so the host's
own verdict is the better answer there, which
`mobile-web-shell-route.test.tsx` already pins. No `key=` expression moved;
the three switches still key differently (host id, pathname, pathname plus
params) and making them agree is a behaviour change for another PR.

The census walks the route tree rather than a list, so a switch added later
is held to both rules without being added here.

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

* test(mobile): mount the copy cases without a client instead of casting one (OTA phase C, C4.1)

The changed-code gate flagged four type assertions on the two cases added
with the clipboard seam: they stubbed an `RpcClient` the way the file's
older cases do, and the gate reads changed lines. Copying reaches no
client at all, so they mount without one, which is both cast-free and a
truer statement of what the path needs.

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

* test(mobile): say what the censuses report and sort the red list by line (OTA phase C, C4.1 round 1)

Round-1 folds, four wordings and one ordering.

`externalLinkOffenders` said "every call site" and reports the line the
name enters the module: a named import once, however many times the module
calls `openURL`, because the import is what the rule is about and what has
to go. Only a namespace import reports its uses, there being no single
line to name. The docstring now says that.

Its red list sorted the rendered strings, which puts `:10` before `:2`.
It now sorts by path and then by line as a number. Pinned against a
written fixture rather than the tree, because the case needs a module with
sites either side of line ten and no module in a closure has to keep
having one — the first fixture used lines 11 and 12, where both orders
agree, and the mutation walked straight through it.

`shell-screen-route.test.ts` still named the files screens in its describe
after the guard stopped being theirs; it names what a switch does now.

`router-seam-census.test-support.ts` excluded `.test-support.ts` from the
walk, which the files census it was extracted from never did. Dropped, so
both censuses walk the same set. Inert today: neither `src/files` nor
`src/source-control` holds such a file, so it only decides the next one.

The `web.tsx` exemption claimed a redirect "that looks like nothing
happened". What was measured: adopting the guard there sends a `..` deep
link through `Redirect href="/h/.."` to the host route, which this PR
keeps native, so the developer lands on the host list with nothing said
about why the page did not open. The route is `__DEV__`-only and the
host's own failure screen is the better verdict.

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

* fix(mobile): see every react-native alias, normalise the host id, surface a refused copy (OTA phase C, C4.1 CodeRabbit)

Three bots findings on #21732, all real.

**Every alias, not the first.** `reactNativeLinkingSites` found namespace
imports with `exec` and inspected only the first binding, so a module
importing the namespace twice and calling `Linking.openURL` on the second
reported no site at all. It reads every alias now and counts a line once
however many meet on it. Red first with exactly that fixture.

**The host id can be an array.** `app/h/[hostId]/index.tsx` read it bare,
and Expo Router answers a repeated key with one: `String(['a','b'])` is
`a,b`, `encodeURIComponent` makes that the single segment `a%2Cb`, and the
segment rule accepts it — so the shell opened a page for a host nobody
has. Through `firstParam`, as the other four switches do. Red first it
handed over `/h/host-1%2Chost-2`, and the empty-array case found a second
one: `[]` is truthy, so a bare read built `/h/` and handed that over too;
`firstParam` answers `''` and the route stays native.

That import pulls the source-control screen state, and with it the lucide
barrel whose `LucideProvider` re-export is the gap the web build patches,
so the suite mocks the barrel as the other suites do. It moves no page
closure: the closure resolves `index.web.tsx`, which this does not touch,
and the index route still measures 3426 modules, 289 local, 22 families.

**A refused copy said nothing.** `PRConflictingFilesSection` caught the
rejection and returned: no tick, no message, a tap indistinguishable from
one that copied. The label now carries the third state, reusing the tasks
page's own wording for it, and the component has its first test. Mutating
the failure arm away reds it.

Its prop narrows to `Pick<PRInfo, 'mergeable' | 'conflictSummary'>`, which
is what it reads and what let the test drop a cast the gate flagged; every
caller holds a full `PRInfo` and satisfies it.

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

* test(mobile): read the censuses' subjects as code, not as text (OTA phase C, C4.1 round 2)

Round-2 additions. A separate commit because `d4b14d54e5` was already
made and this lane does not amend.

**The seam walk parses now.** Matching `X.Linking` in the text named it
inside a comment that talks about it and inside a string that quotes it,
and the named-import regex did the same for a commented-out import.
Checked against the previous implementation, all three fixtures were red
there: the comment case reported lines 2 and 3, the string case reported
the string's line beside the real call, and `// import { Linking } from
'react-native'` reported line 1. The walk builds a `SourceFile` and reads
import declarations and property accesses, so comments and strings are
gone by construction and the quote styles stop being a special case. Cost
measured on the three closure censuses: 3.3 s, unchanged.

**The route census reads the call, not the import.** A switch that keeps
the import while the call goes — deleted, or moved behind a branch that
never runs — looked exactly like one that asks. It now needs both, proved
by mutation: dropping `shellScreenRoute(` from `tasks.tsx` while leaving
its import names `tasks.tsx`. A fixture carries the same rule in
isolation, since every switch in the tree calls what it imports and the
case would otherwise be unfalsifiable against it.

**And recognises a switch by its import** of `MobileWebShellScreen` rather
than by `<MobileWebShellScreen` in the text, so an alias or a line break
the formatter chose cannot hide one and a comment cannot invent one.

The `app/h/[hostId]` root stays written out: deriving it from the manifest
is not a one-liner from here, the manifest being an `.mjs` this test reads
as text. What ties the two together instead is a new case asserting every
registered pathname starts with that prefix, so a page route outside it
fails rather than going unwalked.

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

* test(mobile): read the imported name, not the local one (OTA phase C, C4.1 CodeRabbit)

`import { Linking as NativeLinking } from 'react-native'` went through the
census untouched: the walk compared the specifier's local binding, which
is `NativeLinking`, while the imported name lives in `propertyName` when
a specifier renames it and only in `name` when it does not. Reproduced
before the fix — the aliased import with a call beside it reported no
site at all.

Reading `propertyName ?? name` closes it in both directions. A module
that renames `Linking` is named at its import line like any other, and a
module that imports `View as Linking` is no longer named for a local
binding that reaches nothing. The second was a false positive the old
comparison had by construction.

One more of the same class, found while checking and verified rather than
assumed: `import RN from 'react-native'` typechecks in this project (tsc
accepts it), and a default binding is the whole namespace exactly as
`* as RN` is, so `RN.Linking.openURL` through it was invisible too. The
default binding joins the alias set, which already reports uses rather
than the import.

Four fixtures. Three red on the previous walk: the renamed import, the
local-only `Linking`, and the default import. The fourth — an alias
imported that never reaches `Linking` — passed before and is here to hold
the other half of the rule, that importing react-native is not itself the
offence.

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

* test(mobile): parse each module as its own kind, and read re-exports (OTA phase C, C4.1 CodeRabbit)

Two ways a module reached `Linking` past the census, both reproduced
before the change.

Every file was parsed as TSX. In a `.ts` module `const id = <T>(value:
T) => value` is a generic arrow; as TSX it is an unclosed JSX element,
and the parser folds the rest of the file into the error node. A
`RN.Linking.openURL` after one reported nothing, and so did the same call
with its import above the arrow. The file name goes into the parse now
and TypeScript reads the kind off the extension; `externalLinkOffenders`
passes the real path, which it had all along.

`ExportDeclaration` was never inspected, so `export { Linking } from
'react-native'` put the name back in reach of anything importing that
module while the census saw an import list it was not on. All four shapes
are read — named, renamed, `export *` and `export * as` — and reported at
the export statement, which is the line to delete exactly as an import
is. A re-export of another name, or of `Linking` from somewhere that is
not react-native, stays unnamed.

Seven fixtures. Five red on the previous walk: the `.ts` generic arrow
and the four re-export shapes. The two that pass before and after hold
the other half, that re-exporting is not itself the offence.

The named-import and re-export clauses read `propertyName ?? name`
through one helper rather than two spellings of it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-19 21:06:05 -04:00
84d827a6ab fix(daemon): pause producers when stream backlogs grow (#20947)
* fix(daemon): pause producers when stream backlogs grow

* fix(daemon): reset stream backpressure on socket replacement

* docs(daemon): point retention audit at current reproducer

* test(daemon): validate stream retention audit outcomes

* fix(daemon): bound the stream producer stall and leave a visible gap

Stream backpressure pauses a session's PTY with no deadline: the only
un-pause comes from the consumer draining, so a half-open peer that stops
reading without closing freezes the shell for the rest of the session.

Arm a 60s watchdog on the false->true stream-pause transition (not on the
re-assertions refresh() makes for neighbouring sessions). On fire, mark the
session stall-released: it becomes keep-tail droppable, its backlog is
thinned behind a dataGap, and the producer runs again. The existing dataGap
path makes the renderer restore that pane from the daemon's snapshot, so the
user sees the terminal jump to current rather than sit frozen. The mark
clears once the session's last byte leaves the daemon, restoring ordinary
pausing. Nothing here reports a process exit - loss of contact with a
consumer is not evidence about the child.

Also enable TCP keepalive on the stream socket so a genuinely dead peer
closes and onStreamDisconnected clears the pause.

* test(daemon): put each casting SAFETY: directive on one line

`oxlint-disable-next-line` covers only the line directly after it, so a
rationale wrapped onto a second comment line suppressed nothing and the
casts failed the changed-code quality gate. Drop the remaining JSON.parse
cast for an annotated binding.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:51:59 -07:00
921882619e fix: retire closed editor models from the app shell (#21178)
* fix: retire closed editor models from the app shell

* test(editor): use checked Monaco attachment calls

* Preserve bounded editor view caches when retiring closed models

* docs(editor): describe batched model retirement

* fix(editor): preserve cleanup work across registry replacement

* fix(editor): build editor model URIs with the file scheme

Monaco keys its model registry by `uri.toString()`, and both
`@monaco-editor/react` (via the `path` prop) and the closed-tab disposal
path built that key with `Uri.parse`. On Windows a raw path such as
`C:\repo\a.ts` parses as scheme `c`, which fails the scheme gate in
`modelService._schemaShouldMaintainUndoRedoElements`, so closed-file undo
history was dropped for every file at any size — not only the large files
the tradeoff note covers.

Add `toEditorModelUri`, the one filesystem-path -> model-key function,
built on `Uri.file` so the result always carries the `file:` scheme and
re-parses to itself. Route model creation, disposal lookup and the
still-open ownership comparison through it so all three agree; a
divergence there would dispose a model an open editor is still editing.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:51:55 -07:00
Jinwoo HongandClaude 65cde9bb80 fix(mobile): name the source-control Back controls and split the dock's Close from Back (OTA phase C, C4.3) (#21739)
* fix(mobile): name the custom-key drawer's Back for the accessibility tree (OTA phase C, C4.3)

`CustomKeyModal`'s Back is a bare `Pressable` with a label and no role, so
a screen reader has nothing to announce it as. It is reachable today from
the session sheets and the terminal shortcut settings, so this is a gap
now, not only inside the page.

It surfaces here because `screenTree` takes a screen's directory: C4.4
registering the review route puts the whole of `src/components` under the
Back rule. Fixing it in the PR that registers would make a route entry
carry unrelated accessibility work, so the census gains a case that holds
the arriving trees to the same two rules before the rows land. Red first
it printed exactly what the rule would:

  src/components/CustomKeyModal.tsx:191 role=none label=Back

Once `PAGE_SERVED_SCREENS` has the two rows, `CONTROLS` covers these trees
and the new case becomes a second reading of the same thing; it says so.

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

* fix(mobile): split the source-control header's Close from its Back (OTA phase C, C4.3)

One `Pressable` served both modes — `onPress={onBack}` with a conditional
label, `X` docked and `ChevronLeft` full-screen. The Back census reads a
control by what its handler does, so it sees that one as a Back and then
requires a label starting with "Back", in a mode where the control
dismisses the dock beside the terminal. The cheap way to go green is to
call a close "Back", which satisfies the rule by making the wording wrong.

So the modes become two controls. Embedded presses `onClose` and is named
"Close source control"; otherwise it presses `onBack` and is named "Back
to session". Both carry the button role. The panel stops choosing by mode
and passes both handlers; the dock keeps exactly the behaviour it had, its
dismiss still `onRequestClose` falling back to a pop.

Probed before it was written: run through the census's own predicate, the
post-split shape yields one back control rather than two — `onClose`
matches neither the handler pattern nor a declaration this file holds,
being a destructured prop — so the Close is invisible to the rule and the
presence precondition still holds on the Back.

The component test is what the census cannot do. Nothing else pins this:
no golden names this component and no parity census covers
`src/source-control`. Red first against the single control, both cases
failed on the missing role. And renaming the Close to "Back to close
source control" — the gaming this split exists to prevent — reds the
component test while leaving the census green, which is the whole argument
for having both.

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

* fix(mobile): assert a Back per arriving tree, and name the refresh control (OTA phase C, C4.3 round 1)

The arriving-trees presence case counted controls over the union of both
trees, so one tree answered for the other. Per tree now, in the shape the
block above it already uses per screen module.

Red first, with the mutation round 1 named: rename the route branch's
handler to `onDismiss` and its label to `Return to session`, and the only
Back in `src/source-control` disappears. The per-tree case names that
tree. The same mutation against the union count passes all six cases,
which is what the change is for.

The refresh control had a label and no role, so react-native-web renders
a `div` carrying `aria-label` and a screen reader announces no control.
Its two siblings in this header already carry one.

Two claims in my round-1 report were wrong and I am the reason the body
carried them. The Close pressing `onBack` reds the census's label rule as
well as the component test, not the component test alone; and no fixture
of the post-split shape exists — the shape is read from the real file.
Both were stated from reasoning rather than from a run.

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

* test(mobile): assert an arriving Back per screen, not per tree (OTA phase C, C4.3 round 2)

Round 1 moved the arriving-trees presence assertion from the union to
each tree, which was not far enough. `src/components` holds two Backs, so
the tree answers for both: renaming `MobileDiffReviewHeader`'s handler to
`onDismiss` and its label to `Return to session` leaves every case in the
file green, with `CustomKeyModal.tsx:191` standing in for the screen that
just lost its Back. Reproduced before the change — six passed with the
review header's Back gone.

Per screen module now, which is what the table above already does and for
the same reason its docstring gives: a directory with more than one
control cannot say which one a rule was written about. The two modules
are named and the trees derive from them, so the pair C4.4 adds to
`PAGE_SERVED_SCREENS` is the same pair spelled once here.

Both mutations red on the new case and name the file: the review header's
rename names `src/components/MobileDiffReviewHeader.tsx`, and round 1's
source-control rename still names its own, so this does not trade one
cover for another.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-19 20:51:47 -04:00
NeilandXiro The Dev ee354a35d7 feat(agents): add OpenCode 2 beta support (#21418)
* feat(agents): add OpenCode 2 beta support

Co-authored-by: Xiro The Dev <lethanhtrung.trungle@gmail.com>

* fix(opencode2): support current plugin lifecycle and session storage

* fix(opencode2): preserve lifecycle ordering and full session capture

* test(opencode2): cover setup event bridge

* test(opencode2): cover setup event bridge

* test(browser): satisfy anti-slop naming check

* test(opencode2): cover live form lifecycle

* fix(relay): preserve OMP config directory selection

* test(opencode2): avoid assertions in bridge fixture

* fix(rebase): retain OMP resume and fresh launch behavior

* test: align upstream OMP resume expectations

* test(opencode2): verify rejected form closes waiting state

---------

Co-authored-by: Xiro The Dev <lethanhtrung.trungle@gmail.com>
2026-09-19 17:49:03 -07:00
403c0881e1 Bound AI Vault transcript record assembly before allocation (#20963)
* fix(ai-vault): bound incremental transcript record assembly

* fix(ai-vault): skip one oversized record instead of dropping the session

An agent transcript record over the 10 MiB budget threw out of the JSONL
fold, so the whole session vanished from Agent Session History and from
search. A 10 MiB base64 image or a runaway tool result is ordinary.

The reader now discards the offending record up to its newline and keeps
folding. The in-progress record always starts at `consumedThrough`, which
is what makes both its running size and the resume offset past a discarded
span exact; an unterminated oversized tail leaves the cursor at the
record's start so a still-growing record is re-read rather than guessed at.
Skips accumulate on the resume point keyed by start offset, and the scanner
reports them as a per-session `notice` so nothing is silently lost.

The budget itself is unchanged.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:47:56 -07:00
f87359cda6 fix(runtime): persist acknowledged terminal tab retirement (#21020)
* fix(runtime): persist acknowledged terminal tab retirement

* test(runtime): drain tab retirement fixture writes before teardown

* fix(runtime): explain a refused workspace terminal close

The Sleep-workspace path threw the raw refusal enum ("stale-terminal") as an
Error message, which reaches a CLI user verbatim and a Sleep toast via
describeSleepFailure. Map each refusal reason to a sentence instead.

Also pins two behaviours that had no coverage: the user-visible outcome of a
republished stale-terminal refusal on the web client (the caller cannot tell it
from a real close), and the one-call-per-close invariant that keeps a successor
terminal alive.

The bounded close retry was NOT implemented: notifier.closeTerminalTab carries
only a tab id, so a second call destroys whatever successor took that id.

* test(runtime): build refusal fixtures without type assertions

The changed-code quality gate rejects new `as` casts. Replace the
branded-outcome cast with refusedMobileSessionTabClose, and model the
wire-skew reason as a decoded host answer instead of `as never`.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:38:05 -07:00
db7b57b846 fix(claude): enforce history window quota while reading (#21021)
* fix(claude): enforce history window quota while reading

* test: repair history quota audit dependency and CI import

* fix(native-chat): record why restart reconciliation leaves work unconfirmed

Two silent paths hid the cause of an unconfirmed submission. The reconciler's
bare `continue` on an `unknown` outcome dropped the reason it already carried,
and the transcript read swallowed its error, collapsing an oversize file and a
genuine read failure into the same verdict.

Log both. No control flow changes.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:25:36 -07:00
a445abadd4 fix(browser): bound CDP output for stalled clients (#20949)
* fix(browser): bound CDP output for stalled clients

* fix(browser): log CDP outbound overflow before terminating the client

The outbound queue terminated the automation client silently on overflow, so
the client saw a socket close indistinguishable from a crash. Surface the cap
that tripped and the backlog held when it did.

The queue dropped its backlog before invoking onOverflow, so the counters were
already zero at the callback. Snapshot them first and pass them through.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:24:33 -07:00
4d82149fe5 fix(runtime): reject stale inventory after PTY lifecycle changes (#21014)
* fix(runtime): reject provider inventory across PTY lifecycle changes

* fix(runtime): canonicalize SSH inventory generation keys

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:24:29 -07:00
Neil b766f512ec fix(editor): extract diff first-change auto-scroll to a hook to unblock main (#21738) 2026-09-19 17:06:58 -07:00
OrcaWinandm4air b8f67a6266 Close workspace board when selecting sidebar worktree (#21737)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-19 16:54:56 -07:00
Neil 85a3ba6d42 fix(terminal): align CJK IME preedit spacing (#19367)
* fix(terminal): align IME preedit to terminal cell grid

* fix(terminal): preserve native shaping and reuse IME preedit on repaint

* fix(terminal): preserve native shaping with bounded IME spacing runs

* test(terminal): account for inline preedit subpixel rounding

* test(terminal): keep the IME grid fixture wide at every DPI

* chore: regenerate xterm patch after rebase

* test(terminal): remove IME assertion lint findings

* test(terminal): avoid reflective IME fixture access

* test(e2e): run IME renderer matrix with WebGL available

* fix(ci): restore editor line budget
2026-09-19 16:44:38 -07:00
Neil e4c7632db2 perf(terminal): skip kitty scans for plain PTY output (#21643)
* perf(terminal): skip kitty scans for plain output

* fix(terminal): keep the kitty scan fast path total for absent chunks

The new escape-byte fast path dereferences the chunk before the string
concatenation that used to coerce a nullish value, so an unchecked
caller now throws instead of no-opping. Normalize once at the top.

Also type the AgentTerminalPreview connect mock against the real preload
signature, which turns the stale bare-string replay fixture that tripped
this into a compile error.
2026-09-19 16:26:32 -07:00
Neil abd310e5a3 perf(terminal): skip background SGR scans without ESC (#21646)
* perf(terminal): skip background SGR scan without escapes

* perf(terminal): avoid duplicate renderer risk scans

* test(terminal): pin the carried renderer risk scan tail

The foreground renderer-risk scan splices the carried tail onto the
incoming chunk before classifying it, and nothing covered that ordering:
a pre-gate moved back above the concatenation would silently drop the
refresh for a background SGR split across ConPTY chunks.

Also pins the escape-free ASCII path and the shared global SGR pattern's
statelessness across calls, since the background hit returns mid-loop.
2026-09-19 16:25:58 -07:00
3e7da29767 feat(editor): add opt-in collapsed unchanged regions for file diffs (#11955)
* feat(editor): add opt-in collapsed unchanged regions for file diffs

The combined "View All Changes" diff already collapses unchanged lines into
expandable bands (DiffSectionBody sets Monaco's hideUnchangedRegions), but a
single-file diff opened from Source Control renders the whole file. Reviewing
one changed line in a long file means scrolling past everything else.

Adds a General > Editor setting, default off, that applies the same Monaco
option to the single-file diff viewer. Off keeps today's full-file rendering.

The option is always emitted rather than omitted when off: Monaco retains the
last applied value across an options update, so dropping the key would strand
an open diff in collapsed mode after the setting is turned back off.

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

* fix(settings): register collapse unchanged search entry

* fix(editor): keep diff viewer under line limit

* fix(editor): satisfy diff viewer line budget

---------

Co-authored-by: Dan Cieslak <dcieslak19973@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 15:53:28 -07:00
Neil 844e9df98f fix(editor): keep long file paths distinguishable (#21631)
* fix(editor): keep filename visible in long paths

* fix(review): keep diff filenames visible

* fix(review): prevent path metadata overlap
2026-09-19 15:43:45 -07:00
Jinwoo Hong cef4416115 fix(mobile): name the narrow host header's controls and gate the drawer's hardware back on web (OTA phase C, C2.10) (#21729)
* fix(mobile): name the narrow host header's controls

The header renders two toolbars and the phone sees the narrow one,
whose controls carried neither a role nor a name. A screen reader could
not find them, and C2.9's render check could only assert their absence
at 390 px. The wide toolbar already names every control from the same
state, so the fix is to say the same thing rather than invent wording:
filter, sort, group, accounts, tasks and the search toggle take their
wide sibling's role and label expression verbatim.

The census names a seventh site the plan did not: the Reconnect button
in the status bar above both toolbars, which is shared rather than
narrow and has no wide sibling. It announces through its Text child
today, so it takes the role and the string it already renders.

No layout, style, handler or order changed; the diff is accessibility
props only.

The census parses the file with the TypeScript API and rules that every
Pressable carrying an onPress has a button role and a name, and that a
control both toolbars render is named the same way in both. It keys the
pairing on the handler, because that is what makes two elements the
same control, and asserts each shared handler is found exactly twice,
so a control deleted from one toolbar cannot leave the naming rule
comparing a group of one with itself. Red first, naming all seven
sites by path and line.

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

* fix(mobile): stop the right drawer arming hardware back on web

React Native Web logs "BackHandler is not supported on web and should
not be used." and returns an inert subscription, so inside the shell's
page every open of this drawer put that line on the console and armed
nothing. The gate is the one mounted-bottom-drawer and the file preview
already carry, with the same comment stating the degradation: there is
no hardware back in a WebView, and the shell owns the one the phone has.

The drawer had no render test. This one mocks react-native, the safe
area, gesture handler and Reanimated the way the bottom drawer's
hand-back test does, and reads the call rather than the console: on iOS
and on Android the handler is registered once for 'hardwareBackPress'
and released when the drawer hides, and on web it is never reached. The
three native cases are the control that keeps the web case honest; they
passed before the fix, which is what makes the single red meaningful.

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

* fix(mobile): type the right drawer test's element helper

The tests-typecheck ratchet was red on the previous commit: the drawer's
props declare `children` as required, so passing it as createElement's
third argument left no overload matching. It is a prop here, and the
helper answers a ReactElement rather than a return type borrowed from
createElement.

Test files sit outside `tsc --noEmit`, so only the ratchet sees this;
it is the gate that exists because a type-level pin in an unchecked
test proves nothing.

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

* fix(mobile): write the right drawer test in JSX

Lint was red on the previous commit and I ran it in the same command as
the commit, so it landed: passing `children` as a prop to satisfy the
type checker is exactly what react(no-children-prop) refuses. The
canonical form settles both, so the test is JSX in a .tsx file and the
drawer takes its body as a child again. The StyleSheet mock's generic
needs the trailing comma a .tsx file requires.

Re-proved in this form: with the web gate removed the web case fails
and the three native cases still pass.

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

* fix(mobile): derive the header's naming groups, and read a spread as unknown

Round 1, four folds.

The drawer's comment claimed the console line was observed inside the
shell's page. It was not: the drawer's one caller is the review screen,
whose route C4 serves, so no page closure reaches it today. The gate is
pre-emptive and now says so in its own words rather than borrowing the
bottom drawer's sentence.

The naming rule iterated a hand-written list, so it only ever compared
the six controls both toolbars render. Giving the two
`actions.openFloatingWorkspace` sites different labels left the census
green. The groups are derived from the discovered controls now, keyed
by the handler text, so any handler this header presses from more than
one place is compared and the failure prints both names. The declared
list stays as the precondition it always was: each of the six is found
exactly twice, which is what keeps the derived rule from holding
vacuously over a file with no repeated handler.

The scan read `Pressable` only and dropped any control whose `onPress`
read as empty text, which is what a spread reads as. It reads
`TouchableOpacity` too now, and a spread answers unknown rather than
absent: a control whose handler or whose accessibility props arrive
through one is kept, fails both rules, and prints `spread` rather than
`none`, so it can never be mistaken for a control the scan judged.

Red first on all four: the reviewer's disagreeing-label mutation, a
spread over the a11y props, a spread over the handler, an unlabelled
TouchableOpacity, and a shared control deleted from one toolbar.

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

* fix(mobile): read a braced-empty label as unnamed, from one shared reader

Round 2, two folds.

Only a bare `""` or an omitted attribute read as unnamed, so
`accessibilityLabel={undefined}`, `{''}` and an empty template all left
a control with nothing to announce and the census green. Reproduced on
both Tasks sites with each of the three shapes before the fix. The
reader unwraps a braced expression now: a string or a no-substitution
template answers its own text, and the identifier `undefined` answers
empty, so all three read as unnamed.

That reader was a near-verbatim copy in both censuses, which is how one
of them could have gained this rule and the other kept the hole. It
lives in one module under mobile-web-shell now, named for what it reads
and typechecked by mobile tsc rather than by the ratchet alone. Both
censuses import it and neither changed an assertion; their diffs are
the deleted copies and the import.

Red first, five mutations: the three empty shapes on both Tasks sites
here, and `{undefined}` and `{''}` on the tasks Back, which the page
census now catches too and did not before.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 18:33:20 -04:00
Jinjing 93d245e358 Sort dev builds by timestamp instead of semver (#21720)
Dev build base versions can move backwards when a branch is cut before
the latest main build. Their embedded timestamp is the authoritative
"newest" signal for the picker. For dedicated release repos, compare
publishedAt timestamps before falling back to semver comparison.
2026-09-19 15:15:23 -07:00
OrcaWinandm4air 9309350864 fix(chat): enforce legacy import byte budget during reading (#20976)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-19 14:53:31 -07:00
89acf1e1fa fix(plugins): release diagnostic logs after successful uninstall (#21185)
* fix(plugins): retire log owners after successful uninstall

* fix: address memory PR review regressions and withdraw false positives

* fix(plugins): fence stale activation after uninstall

* chore: allow durable plugin uninstall audit evidence

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 14:53:28 -07:00
2741bdad38 fix(session): remember closed and initialized runtime tabs (#21630)
* fix(session): persist defaultTerminalTabsAppliedByWorktreeId

Host persist snapshots wrote tabs but omitted this write-once map, and
full session replaces / hydration treated omission as "never applied".
Union the marker across persist and hydrate so default terminals are not
re-spawned on every launch or re-attach.

Fixes #18117

* fix(runtime): stop re-seeding a runtime-owned workspace the user emptied

Focusing a workspace owned by a remote runtime created a terminal every
time, and sometimes two.

The mirror could never record the closed-last-terminal state. A host
snapshot with no terminals produced `nextTerminalTabs === null`, which
`withWorktreeEntry` turns into a deleted key -- and a missing row is
exactly how every seeder spells "never initialized"
(initial-terminal.ts). Keep an explicit empty row instead, so the remote
path reads the same tombstone the local one already honours. A worktree
that never had a terminal still gets no row, because `sameTerminalTabs`
treats a missing row and an empty one as equal; removal frames and
synthesized unpublished frames keep deleting, since neither is evidence
the user emptied anything.

The duplicate had a second cause. `requestedInitialTerminal` was a `let`
inside the session-tabs subscription closure, so "one focus creates at
most one terminal" held only for as long as that closure lived. Its
effect re-runs whenever the environment, connection generation, pairing
revision, or session-ready flag settles -- all of which move during a
workspace switch -- so a second closure re-armed the flag while the first
create was still in flight. That is the asymmetry in the report: one
terminal when arriving from the landing screen, two when arriving from
another workspace. Latch the bootstrap per worktree in a module-scoped
set instead, modelled on web-runtime-wake-terminal-respawn.ts, released
when the create settles. The closure flag stays alongside it so a failed
create still does not retry on every later frame of the same
subscription.

Fixes STA-6173.

* fix(runtime): harden the runtime-owned initial-terminal bootstrap latch

Follow-up on the STA-6173 fix, addressing restore-time safety gaps found in
review.

- Decline the bootstrap on a synthesized unpublished frame
  (`UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH` at version 0). That frame is the
  runtime saying "ask me later", not a host with zero terminals; seeding on it
  can duplicate a pane the host is about to republish after a restart. This is
  the same "ask me later" frame the tombstone write already refuses to treat as
  the user emptying the workspace.
- Release the module-scoped bootstrap latch on worktree tracking teardown and
  environment teardown, mirroring web-runtime-wake-terminal-respawn. A create
  RPC that never settles during a disconnect would otherwise leave the
  per-worktree key set and suppress the next bootstrap after reconnect.

Tests:
- New per-worktree and per-environment latch-release cases.
- New "unpublished frame declines" bootstrap case.
- New hook-level regression pinning the second defect end to end: a forced
  active-subscription re-run while the first create is in flight seeds exactly
  one terminal (two on the pre-fix tree).

* fix(runtime): key the initial-terminal bootstrap latch per environment

Addresses review on the STA-6173 hardening.

- Key the bootstrap latch by (environment, worktree), not worktree alone. A
  worktree id is `repoId::path` with no host component, so the same id can be
  live on two paired runtimes at once. The latch was cleared wholesale on any
  environment teardown, so tearing down environment A released environment B's
  in-flight key and a fresh B subscription could seed a duplicate — the STA-6173
  defect through another door. Environment teardown now clears only its own
  keys; worktree teardown clears only that (environment, worktree).
- Hold the latch after a successful create until a mirrored `tabsByWorktree` row
  exists. The snapshot refresh the create awaits can resolve on an empty,
  unconfirmed frame that leaves no row; releasing then let a later effect re-run
  seed a second terminal. A failed create still releases for retry. The latch
  claim/create/release now lives in web-runtime-initial-terminal-bootstrap-dispatch.ts,
  keeping active-session-subscription within its line budget.

Tests (each mutation-tested against its own regression):
- Cross-environment: a create for env B in flight, env A torn down, a fresh B
  closure must still decline. Fails when env teardown sweeps all environments.
- Hold-until-row: a create that resolved without mirroring a row must not let an
  effect re-run seed again. Fails on unconditional release.

* fix(runtime): release the bootstrap latch when the create returns failed

createWebRuntimeSessionTerminal never throws: the operation catches RPC and
network failures and returns `{ status: 'failed' }`. The dispatch helper
released the latch only from `catch` (dead for that path) or once a
tabsByWorktree row existed (false after a failure), so a failed create left
the latch held and suppressed every later auto-seed for that environment's
worktree until teardown -- the opposite of its own doc comment.

Capture the outcome and release on a returned failure as well, so the next
focus can retry.

Regression: a create that resolves `{ status: 'failed' }` followed by an
effect re-run must create again. Fails on the previous release condition.

* fix(runtime): release a parked bootstrap on the mirror's next frame

The previous commit released the latch on a returned failure, but a create
that *succeeded* with no mirrored row yet had no release at all: the
row-conditional check was the only exit for the success path, so a host that
accepted the tab while the mirror never got a frame held the latch until
environment teardown and suppressed every later auto-seed for the worktree.

Give the latch two phases. `creating` blocks other closures while the RPC is
in flight. A success with no row is parked as `awaiting-mirror` instead of
held, and the next frame the mirror accepts for that worktree releases it --
that frame is the mirror's answer either way (a row now exists and the
predicate declines on its own, or the host genuinely has no terminal and a
retry is right). A create still in flight keeps its claim: releasing it on a
frame would reopen the re-armed-closure race the latch exists to close.

Also correct the closure-flag comment: `requestedInitialTerminal` is set only
after the dispatch resolves, so a thrown create never sets it and a later
frame may retry. The flag records that this subscription already owned a
create; it never described a failed one.

Regressions, each mutation-tested against its own term:
- success with no row, then the mirror's empty answer, then a fresh closure
  -> must create again (fails when success-with-no-row is held instead of
  parked, and when the subscription does not call the frame release)
- a mirror frame releases an awaiting-mirror claim but never a pending
  create (fails when the release ignores the phase)

* fix(runtime): let a failed create retry inside its own subscription

The returned-failure release freed the module latch but the caller still
latched its closure-local requestedInitialTerminal whenever the dispatch
reported it owned the create, including a create that returned
{ status: 'failed' }. A thrown failure never set it and retried on the
next frame; since every RPC and network failure is reported as a return,
the live path was the one that suppressed the whole subscription. The
dispatch now reports false for a failure whichever way it arrives.

* fix(runtime): close the second re-seed door on an emptied workspace

ensureWebRuntimeWorktreeTerminalAfterWake read tabsByWorktree through
`?? []`, and the only guard that could have seen presence was &&-gated on
the value that erased it, so it could never fire for a tombstone. It runs
on every activateAndRevealWorktree, not only after a wake, and a
tombstoned workspace routes exclusively here: the stream-frame path
returns at localTerminalCount === 0 before reaching its own guard. So the
workspace was re-seeded on every focus regardless of the mirror fix.

Two states shared one line and now do not. With no rows the workspace is
being seeded for the first time and the decision goes to
shouldAutoCreateInitialTerminal with presence read by Object.hasOwn; with
rows present the question is whether a woke workspace's chrome outlived
its PTYs, which the tombstone says nothing about. Both arms are pinned,
because reverting only the respawn arm broke no existing test.

* fix(runtime): stop the worktree-id re-key inventing a tombstone

canonicalizeTerminalSessionWorktreeId read the source row through `?? []`
and always wrote the target one, so a workspace with NO tabsByWorktree row
came out the other side with an explicit empty one. That is the
closed-last-terminal tombstone, and this PR's new activation-path reader
honours it, so the re-keyed workspace never gets its initial terminal.

Guarded on the source row's presence, which is the guard the sibling
keyed maps six lines below already use. Mutating the guard to test
emptiness instead of presence breaks the assertion that a real tombstone
still survives the re-key, which is the distinction that matters.

* fix(runtime): reconcile session tab bootstrap integration

* fix(runtime): preserve terminal wake launch options

* test(session): annotate cross-project persistence fixture

* test(session): remove obsolete typecheck suppression

* test(session): keep persistence regression in renderer project

* refactor(runtime): remove inert snapshot recovery wrapper

* fix(runtime): keep removal-frame import merge-safe

* fix(runtime): deduplicate merged removal-frame import

---------

Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-19 14:30:25 -07:00