Commit Graph
11548 Commits
Author SHA1 Message Date
Jinjing 4c1c8a02d6 Ensure draft comment textarea auto-focuses and preserve drafts through e
- Focus textarea on mount via requestAnimationFrame for reliable focusing
- Add onDomNodeTop callback to focus textarea when zone reaches viewport
- Preserve pending draft when editor model refreshes and re-anchor on reload
- Add editor.getModel() checks before opening and re-anchoring drafts
- Test that textarea is focused on creation and errors are surfaced
2026-09-21 22:54:20 -07:00
Jinjing f5d7a59bc9 Preserve inline draft comments when switching diff views
- Reanchor draft zones to new models when file/line mapping changes
- Disable draft mode on large diffs to maintain performance
- Enhance draft card UX: shadow depth, outside-click handling, toast errors
- Carry draft body and position when reopening comments
2026-09-21 22:54:20 -07:00
Jinjing 90c404d578 feat(diff-comments): draft inline notes as editor view zones
Move comment drafting from floating popover to inline view zone. The draft card now appears in the editor flow, preventing overlap with code and integrating naturally with the diff layout. Includes styled margin indicator, auto-resizing textarea, and keyboard/submission handling.
2026-09-21 22:54:20 -07:00
Jinjing 9d4039b8c5 Lower filter chip contrast to indicate read-only status (#21751)
* fix(cmd-j): lower filter chip contrast to indicate read-only status

The scope is seeded from the sidebar, not chosen in the command palette, so the chips should read as metadata rather than action pills offering to undo an action the user never took.

- Redesign chips with reduced visual weight: no border, no background color
- Add "Scoped to" prefix and muted text color to clarify metadata role
- Separate chips with subtle dots instead of relying on spacing
- Hide dismiss icon (X) until hover/focus to further de-emphasize the action

* refactor(cmd-j): add visual icon to filter indicator

- Add ListFilter icon anchor for better discoverability
- Improve filter chip button styling with better hover/focus states
- Move label to screen reader only for cleaner interface

* refactor(cmd-j): adjust filter chip appearance and i18n

Refine filter chip styling with adjusted height, padding, and colors to de-emphasize read-only state. Add "scoped to" label and complete internationalization coverage across all locales.

* improve filter row alignement
2026-09-21 22:53:20 -07:00
Jinwoo Hong 5769eb6724 fix(mobile): a granted microphone tap launches no permission activity, and an aborted start says so (#22150)
* fix(mobile): a granted microphone tap must not launch the permission activity

Every Android mic tap raised GrantPermissionsActivity although RECORD_AUDIO
was granted: Expo's askForPermissions path has no granted check and goes
straight to delegateRequestToActivity. The activity pauses and resumes the
React host for 50-115 ms.

The module now answers from getPermissionsWithPermissionsManager when the
permission is already held. iOS needs nothing: EXPermissionsService returns
on a granted status before it reaches the requester.

The resume behind that activity forces a tabs reconciliation, which can
transiently clear the active handle and so flip canSend, the hook's `enabled`.
That reached cancel() through the !enabled Effect -- the same cancel the user's
own has -- so a tapped start ended at idle with nothing shown. abandonDictation
now carries a reason: null is the user's cancel and stays silent, anything else
reaches onError when a start or a recording was underway.

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

* test(mobile): type the dictation device log instead of asserting it

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

* test(mobile): the input-closed test names what is established and what is open

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

* fix(mobile): a dictation abort must not write over the start that replaced it

abandonDictation read what it would report before awaiting the desktop cancel,
and nothing re-checked identity after it resumed. A start landing inside that
round trip reaches recording, then the stale abort reported the closure over it
or idled it. It now captures the generation it bumps to and returns if the
generation moved, the guard the finish check and the desktop start already use.

capture.open()'s catch had the same hole: a rejection arriving after a disable
had already reported the closure ran applyStatus('idle') and rethrew, wiping it.
The catch now makes the same check the arm below it makes, and returns without
rethrowing, because the composer toasts whatever start rejects with.

Both races predate this branch; probed against b9643365ba with the same cases.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 01:49:22 -04:00
Jinjing c3c4b4ec02 fix(browser): notify state on offscreen page navigation commit (#21703)
* fix(browser): notify guest state on offscreen page navigation commit

Offscreen pages have no renderer to publish their row, so the
navigation commit is the only moment paired clients learn the new URL.
Every failure path already announces; the success path was missing
this notification, preventing background links from mirroring.

Extend the E2E test with helpers to verify background links mirror
before surfacing as panes, reading both client and host state to
isolate failure causes.

* test(e2e): validate host tab response in link-open routing

Replace unsafe type assertion with runtime validation of the response
structure. Add defensive checks to ensure tabs exist and have the
expected shape before processing, improving test robustness when the
remote host response is incomplete or malformed.
2026-09-21 22:48:39 -07:00
Neil 8fb0ba671b fix(ssh): keep the generation floor when a target is removed and recreated (#22146) 2026-09-21 22:20:13 -07:00
Jinwoo Hong 5064469687 fix(mobile): a same-build cache hit persists the fresh manifest (OTA phase D1) (#22139)
* fix(mobile): persist a fresh manifest onto the generation on disk

A route-grant edit on the desktop moves no asset, so the bundle is
published under the build id it already had: the cached generation holds
the right bytes under a manifest an edit behind, and that stored manifest
is the whole of an unreachable host's verdict.

The store gains one operation for it. Refused unless the manifest names
exactly the bytes on disk — same build id, and the same asset list read
through the contract's own serializer, which is the string the id is a
digest of. Written beside and renamed over, so a write that fails leaves
the manifest the assets were downloaded with. A refusal is a return
value, never a throw: it costs freshness, never the generation.

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

* fix(mobile): write the manifest through on a same-build cache hit

The same-build hit in `onManifestRead` opens the cached generation and
now also asks the store to rewrite the manifest beside it, and the
generation the session holds carries the fresh routes from that point.
Without it every offline verdict lagged a grant edit: `onCacheRead` reads
the stored routes, and nothing on this path wrote them.

The manifest travels whole on `manifest-read`, because the store compares
its asset list and a projection rebuilt from the fields a transition
reads would name other bytes. The fallback fixture that read a newer
manifest under the cached build id now uses a build of its own, which is
what makes the download it is about happen at all.

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

* fix(mobile): every same-build manifest read persists, whatever the route verdict (round 1)

A verdict about this route is not a verdict about the manifest. A fresh
list that takes this screen native, or that names a bundle this shell
cannot open, still grants or revokes the other routes the same assets
serve — and what is stored beside those assets is the whole of the next
offline verdict. Both returns left it unwritten, so an offline entry kept
grants the desktop had already taken away.

The same-build read is now taken before the route verdict: the native
and wall returns carry the fresh routes on the generation they hold and
emit the persist, exactly as the open does. The different-build arms are
untouched, wall included, which still fetches nothing.

`readMobileWebShellReachability` moves to `mobile-web-shell-reachability.ts`,
the module its test was already named for: the reducer was one line under
the 300-line cap and this fold needed the room.

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

* fix(mobile): make the manifest swap crash-safe at the store (round 2)

`moveFile` deletes the destination before it moves, because expo offers
no atomic replace, so a failure after that delete left a generation with
no manifest at all — which reads back as "no activation" and drops the
host's cache. A phone whose host is unreachable loses its whole workspace
to one interrupted rename. The store's fake threw before that delete, so
the suite never exercised the ordering the adapter really has.

The write is now three steps inside the generation directory: the fresh
manifest to `manifest-next.json`, the old one away, the fresh one over
it. The old manifest is never deleted before the whole of the fresh one
is on disk, and `readActive` settles every window it leaves — both files
present finishes the swap, a pending manifest alone is adopted, and one
that is torn or names another build is discarded with the old one kept.
A pending file that cannot be settled deletes nothing, because it may be
the only manifest left and the next read can still adopt it.

The asset-path guard refuses the pending name too, or an asset could be
read back as an activation. `joinUri` moves to a module that imports
nothing: taking it from the file-system module pulled `expo-file-system`
into everything that addresses the cache, which is the import the store's
testability rests on not having.

The refusal rule now names where the id-to-assets refine actually lives.
It is on the host's strict schema, not on the loose reader the phone
parses with, so the store's asset comparison is the phone's own check
rather than a restatement of one already made.

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

* fix(mobile): a walled same-build manifest is not persisted (round 2)

Round 1 wrote every same-build manifest through, the walled ones
included. That is the one read where the fresh manifest must not reach
disk: an offline entry skips the compat check, so a stored manifest this
shell has just declared it cannot read would have the next offline entry
open a page under the grants of that bundle. What is on disk stays the
last manifest this shell accepted, and the held generation keeps its
routes with it, so the session's record still matches what was written.

The native-route arm keeps the write: a route this build cannot serve is
not a bundle it cannot read, and the fresh list still governs the other
routes those same assets serve. Its round-1 test is now a control that
pins the wall persisting nothing.

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

* fix(mobile): the phone refuses a manifest whose build id is not its asset digest (round 3)

The id is a cache key and a claim about content at once, and only the
host checked the two against each other. The phone read the same document
loosely and never recomputed the digest, so a stale or forged id reached
the shell — which treats an id it already holds as the same bytes and
opens the generation on disk without paging one.

The reader now carries the host's refine, so a manifest that reaches the
reducer under the cached build id names the same serialized asset list by
construction. The two checks in that reader are one `superRefine` with
the cheapest first and the first issue returning: the digest is the only
one that hashes, and a manifest already over the allocation ceiling must
not be hashed to be refused. The store's comparison stays, as the second
reading of one rule rather than a rule of its own: its argument is a
plain object, and nothing in the type says which parse it came from.

The fixtures that published a literal id now derive it from the assets
they name, which is what the host does. The one test that needs a single
id over two asset lists still names it, because that collision is what it
is about. Every one of the 13 manifests in the golden corpus was already
digest-correct; the replay suites pass unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 01:17:09 -04:00
Jinwoo Hong 35897da0aa fix(mobile): serialize a list the engine nested inside a paragraph (#22145)
`insertUnorderedList` puts the `<ul>` inside the `<p>` it was given rather than replacing it —
measured on WebKit 26.4 and Chromium 147 both — and `blockMarkdown` read such a paragraph inline.
A bullet list the user typed came back as the paragraph's own text with no marker, so it did not
survive a markdown round trip, on the page and in the native WebView alike.

The serializer now reads structure wherever the list sits: text before it is a paragraph, the list
is a list, text after is a paragraph. The DOM is left as the engine made it and no branch asks
which engine is running. The parse side needs no mirror — it already renders `- x` as a top-level
`<ul>`, which is the shape the fixed serializer reports, and the flat control case pins that.

The unit fixture is built through the paragraph's own `innerHTML`: the HTML parser closes a `<p>`
before a `<ul>`, so a markup string on the editor gives two siblings and would measure the flat
shape. Each case asserts the nesting it got before it reads anything.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 01:11:13 -04:00
Jinwoo Hong 7650abe224 fix(macos): tell the user when Orca's terminal service can't read their folder, and walk them through the fix (#21923)
* fix(macos): tell the user when Orca's terminal service can't read their folder

On macOS, a terminal daemon that survived an app update can be refused access to
a workspace under Documents, Desktop, or Downloads while the Orca app itself can
still read it. Terminals opened there die with "Operation not permitted" and
nothing on screen explains why. The daemon has reported `cwdReadableByDaemon` on
every create since #18043 and main has emitted `daemon_pty_cwd_denied` on proven
divergence since then; the field data says 1,438 users hit it in 21 days. What
was missing was the notice.

The verdict itself moves off `access()`. A grant-less probe on an affected
machine showed a TCC mode where `access(R_OK|X_OK)` passes on `~/Documents` and
`opendir` still fails, so the check now does what a shell listing its cwd does:
`opendirSync`, one `readSync`, `closeSync`. Only EPERM/EACCES reads as denial —
a missing path, a non-directory, or an unexpected error still reads as readable,
so a non-permission failure can never masquerade as one. The same probe is what
the app side compares with, through one oracle shared by the telemetry emitter
and the notice, so the spawn path reads the directory once.

Proven divergence now also records evidence in main: one entry, keyed by the
daemon's pid, start time and launch nonce, carrying an opaque digest of that
identity and the folder class. No path leaves main. The existing focus-time
`macTccAttribution` poll carries it to the renderer, which raises a second toast
latched per daemon scope: dismissed stays dismissed, and a restart mints a new
identity so the poll returns null and the toast clears with no post-restart
probe. If the replacement daemon is denied too, about 31% of cases, the next
spawn re-records under the new scope and the notice returns, now with the
re-allow sentence doing the work.

No new IPC channel, no daemon protocol field, no polling change, and nothing new
on the spawn path beyond one `opendir`. `daemon_folder_access_notice` counts
shown, dismissed and open_manage_sessions against `daemon_pty_cwd_denied` as the
denominator; `shown` is emitted from main the first time a scope leaves the IPC
handler, so the renderer carries no telemetry plumbing for it.

* fix(macos): clear folder-access evidence only when the same folder class reads back

A readable spawn in ~/code said nothing about a Documents denial but was
hiding the notice; retire the evidence only when the daemon reads a folder
of the class it was denied on.

* fix(macos): say what a terminal-service restart actually does

The Manage Sessions restart confirmation still described the product as it was
before agents resumed themselves: it promised panes showing "Process exited"
that the user reopens by hand, and mentioned legacy-protocol sessions nobody
outside the daemon code can act on. Open terminals and agents come back on
their own now, so the old copy made a routine remedy sound like data loss.

It also called the thing a "daemon". The same restart is about to be offered
from a user-facing fix dialog, so both surfaces now say "terminal service", and
the confirm button is just "Restart".

The new body adds the one fact the old one never stated: terminals on remote
hosts are not affected. Translations of the two changed strings are dropped so
the five non-English locales fall back to English rather than keep showing copy
that is now wrong.

* feat(macos): give the denied-folder notice a fix the user can follow

The folder-access toast told the user their terminal service could not read
Documents and then handed them a paragraph: restart from Manage Sessions, and
if that does not work, re-allow Orca in System Settings. Both halves were
guesses. Roughly a third of restarts do not fix it, and the user had no way to
know which case they were in before spending every open terminal on finding
out.

Main can now answer that. `daemon-folder-access-probe.ts` forks a short-lived
child of the app binary the same way the daemon itself is forked, runs one
opendir/readdir/closedir against the denied path, and prints a single JSON
line. macOS attributes a TCC grant to the process that forked the child, so a
child of the app running now answers exactly the question the running daemon
cannot: would a replacement daemon get in? The child goes through the shared
child-process wrapper, never a shell, with a 3s deadline, a 1KB output cap and
an environment scrubbed to PATH/HOME/TMPDIR. Every failure — timeout, bad
output, spawn error — reads as `unknown`, never as a verdict.

That answer rides out as `restartWillHelp` on the evidence the existing
focus-time poll already carries, and the toast becomes a title and two buttons:
Fix… and Not now. Fix opens a dialog with the two real steps. When the grant is
already in place, step one is shown as done and Restart is live. When it is
not, step one is open and Restart is disabled until it completes — which it
does by itself, because the poll re-probes while the answer is still no, and
returning from System Settings is the moment that lands. An unanswered probe
never accuses the user of a missing grant; it leaves both steps open.

Restart calls the management API directly rather than stacking the Manage
Sessions confirmation on top, since the dialog already states the consequence.
Success replaces the steps with a done line and takes the toast down; failure
says so inline and leaves the button usable.

System Settings opens through the existing developer-permissions pane opener,
which takes an id rather than a URL, with Files and Folders added to it. The
event's action enum now also counts fix_opened, settings_opened,
restart_clicked and — emitted from main when a replacement daemon's first spawn
lands in the folder class the previous one was denied on — whether the restart
actually worked.

* fix(macos): let the folder-access notice return after a poll that read no daemon

A daemon identity reads as null during any reconnect blip, and the poll reports that as
"no mismatch". The notice dismissed itself and then never showed again for that daemon,
because the once-per-daemon latch still held its scope. Only "Not now" should latch.

* fix(macos): say what the folder-access notice costs the user

One line read like a stray warning. The toast now says who is blocked and what fails,
and still leaves the steps to the fix dialog.

* fix(macos): give the folder-access toast one action and the X, like every other toast

"Fix" is the only button; the X dismisses. Sonner fires onDismiss for programmatic
dismissals too, so the post-restart takedown now goes through the store and the hook,
and only a user's X is counted as dismissed.

* fix(macos): keep the fix dialog's steps a checklist and put the one action in the footer

Buttons inside each step made the list look like a form, and a footer Close duplicated
the X. The footer now carries the active step's action, with a ghost Cancel; a probe
that could not answer says so under step 1 instead of showing a check.

* fix(macos): let the checklist show the fix landed instead of saying so

A hedged sentence addressed to the user read like chat. On success both steps check
off and the footer offers Done; the unanswered-probe helper is a status, not advice.

* chore(i18n): drop the fix dialog's unused close key

* Revert "chore(i18n): drop the fix dialog's unused close key"

This reverts commit 365915df48.

* chore(i18n): drop the fix dialog's unused close key

* fix(macos): tell step 1 what to do when the folder toggle is already on

Users who need step 1 usually find Orca already allowed in System Settings; the grant
is recorded but not honoured for the daemon. Re-toggling re-records it.

* fix(macos): drop the unverified toggle instruction from step 1

Nothing has been confirmed to fix a grant that is already on, so the step says only
what the probe knows.

* refactor(macos): share the tccutil reset and bundle-id read behind one module

Clearing a macOS TCC row is about to have a second caller: the daemon
folder-access fix (STA-7948) needs the exact `tccutil reset` the computer-use
helper already issues. Extract both it and the PlistBuddy bundle-id read into
src/main/macos-tcc-reset.ts so the two remedies cannot drift apart.

The extracted calls go through runProcessSync rather than a fresh
node:child_process import: the spawn chokepoint's ratchet holds the direct
importer count at a pin, and a new module with its own spawnSync would raise it.
Behaviour is unchanged except that both calls now carry a 10s bound, and the
computer-use test asserts the same argv against the chokepoint's options.

* feat(macos): offer a permission reset when restarting the terminal service cannot help

About a third of the users who see the folder-access notice are still denied by
a freshly forked daemon even though Orca itself is allowed under Files and
Folders, so the restart the dialog offers cannot fix anything for them. That
state previously had one action: open System Settings, where the toggle they
would look for is already on.

The denied state now offers "Reset permission". Main clears Orca's TCC row for
that folder class with tccutil, then reads the folder from the app itself so
macOS raises its prompt against Orca rather than the daemon, then forces a
fresh-daemon re-probe that bypasses the poll's reuse interval. The dialog
re-renders from that verdict: allowed turns step one green and offers Restart,
still denied says so, and a refused reset points back at System Settings.

Nobody has confirmed this remedy on an affected machine, which is why main emits
the re-probe's verdict as reset_outcome_allowed/still_denied/unknown. Those
three, plus reset_clicked, are the evidence that decides whether the feature
stays.

* fix(macos): say what the permission reset does, and keep System Settings as the fallback

Step 1 was labelled like a Settings task while the button did something else, with two routes
in the footer for one step. The denied state now names the step for what the reset does,
explains it under the step, and shows System Settings only after a reset fails or leaves
things blocked.

* fix(macos): count a folder-access restart only against evidence that survived

The stored denial is the prior denial, so a second copy of it outlived the
one event that retires it: a daemon that read its own folder back cleared the
entry but left the copy, and the next daemon's first denial was then reported
as a restart that had never happened.

Track the outcome on the entry itself, drop the spawn-path probe (ten denied
terminals forked ten probe children the focus-time poll re-runs anyway), and
stop emitting `shown` from a getter the reset path calls for data. The
renderer's toast latch is what decides a scope is shown, so it emits it.

Both accessors now read one identity-matched entry.

* refactor(macos): name the folder-access verdict instead of encoding it as a tri-state

`restartWillHelp: boolean | null` re-encoded a verdict the probe already
returns as a named union, so every reader had to remember that `false` meant
"Orca itself must be re-allowed" and `null` meant "no answer".

`freshDaemonAccess: 'allowed' | 'denied' | 'unknown'` says it, end to end
through main, the IPC payload, the preload mirror and the dialog. The reset's
outcome event becomes a lookup. No user-visible string changes.

* refactor(macos): give the folder-access notice one latch instead of three

Two refs in the hook and a field in the store tracked the same fact, and the
dialog reached the hook through a store field plus an effect just to take its
own toast down before sonner echoed the dismissal back.

The store now holds the visible scope and the scopes the user closed, and
exposes the three things that happen to a notice: it is shown, someone else
retires it, or the user dismisses it. The dialog calls retire directly and the
effect is gone. `settingsIsFallback` loses an argument that was always true at
its only call site, so it becomes the local it always was.

* refactor(macos): stop blocking main on the tccutil reset

Two spawnSync calls with a ten-second timeout sat inside an async IPC handler,
so clearing a TCC row held main's event loop for as long as either binary took.

Both now run through runProcess. The computer-use caller that shared them was
already async, so it awaits them.

* test(macos): run the folder-access probe script against real paths

Every other test mocks the spawn away, so the minified child script — the one
piece that duplicates enumerateDirectoryOnce's errno mapping — had no oracle.
It now runs against a temp directory, an absent path, a file, and a directory
whose mode withholds it, which is skipped for root and on Windows.

* refactor(macos): read the folder-access entry through one identity match

All four callers that ask "is this evidence still this daemon's?" now go
through the same private accessor, so the rule the canonical path depends on
lives in one place.

* fix(macos): keep folder evidence through a failed health read, and make a forced re-probe always probe

A rejected attribution-health read nulled the folder evidence on the same poll, which the
renderer read as "cleared". A forced refresh after a reset returned early on an older
settled verdict. The dialog also closes when a reset finds the evidence gone, and stops
showing the unverified helper once the restart is done.

* fix(macos): name the folder in the access-notice scope

One daemon denied two protected folders kept one scope, so the toast, the
fix dialog, and the tccutil reset could each be about a different folder.

* refactor(macos): derive the folder-access dialog from the latest verdict

The store held an `open` flag and a mismatch frozen at the moment the toast
was raised, so the dialog could open on a stale verdict and its remedy state
could survive a close. It now keeps the latest verdict and the scope the user
opened, and the dialog is shown only while the two agree.

* fix(macos): offer the permission reset only where there is a row to reset

A workspace symlinked out of Documents or on an external volume can be denied
too, and the dialog offered a reset that main refuses. One shared list of the
TCC-backed folder classes now decides both.

* fix(macos): give the permission prompt's read a deadline

An unanswered macOS sheet blocks the app's folder read for as long as the user
ignores it, and the fix dialog is modal and busy until that read returns. The
wait now ends after a minute and reports an unknown outcome rather than
probing under the sheet.

* fix(macos): count the folder-access notice once per scope

A reconnect blip reports no daemon, which takes the toast down and lets the
same scope raise it again. Both raises counted as separate notices, inflating
the denominator behind the affected-user rate. The two latches are now one
map from scope to phase, and the count follows first insertion.

* fix(macos): drop the restart warning once the restart is done

Step two ticked green while its helper still warned that open terminals and
agents would restart, which had already happened.

* fix(macos): keep the folder-access toast up when the fix dialog opens

Sonner deletes a toast after its action button runs unless the handler
prevents the event, and it does so without calling onDismiss. Clicking Fix
therefore took the notice off screen while the scope stayed latched as
visible, so cancelling the dialog left no way back to it.

* refactor(preload): reuse the shared daemon cwd class instead of copying it

The five folder classes were hand-mirrored in preload behind a comment saying
preload cannot depend on main-only modules. The enum lives in src/shared,
which preload already imports from elsewhere, so the copy could drift.

* refactor(macos): close the fix dialog when its evidence disappears

A null verdict left the opened scope set, so the same scope coming back
remounted a checklist nobody had opened. Clearing it on a null verdict also
makes the dialog's scope key redundant, so it goes.

* fix(macos): let each fix-dialog button report its own work

The footer swaps the reset for a restart as soon as a poll says the grant
landed, which can happen while the reset is still running. Both buttons read
their label off the dialog being busy at all, so the restart button appeared
spinning as "Restarting…" for a restart nobody had started.

* fix(macos): clear the reset failure once the permission is granted

"Couldn't reset the permission" stayed on screen after the user granted it in
System Settings and the probe read allowed, contradicting the ticked step
above it. Its sibling line was already gated on the same verdict.

* fix(macos): end the folder-access remedy with the evidence it is about

Two ways out were missing. A reset that cleared the evidence closed the dialog
but left the toast on screen, because only the poll retired it; the store now
retires the notice whenever a verdict comes back null, so both callers get it
and the hook's own branch goes. And the opened scope survived a verdict for a
different scope, so the original one returning later reopened the dialog with
nobody having asked for it.

* fix(macos): keep the folder prompt off main's spawn path

The app-side readability check moved from accessSync to opendir when the
notice was added. TCC lets accessSync through but gates opendir, so on a
machine that has never granted Orca the folder, spawning a terminal there
raised the macOS sheet and froze main until the user answered it. The read is
async now and the spawn no longer waits for it. The blocking variant keeps a
name that says so, and the reset module's own copy of the read is gone.

* fix(macos): only say a folder is still blocked when something re-read it

Two paths reached "Still blocked after the reset." with no verdict behind it:
an unanswered prompt, where the reset returns the verdict stored before it
ran, and a re-probe that could not answer. The reset now returns the same
access it reports to telemetry, and the line waits for a real denial.

* refactor(macos): let the folder-access refresh decide when to skip itself

The poll handler re-implemented the refresh's own two guards, a null entry
and a settled allowed verdict, so each had to be kept in step by hand.

* fix(macos): stop the daemon blocking on its own folder read

The daemon reads the requested cwd before forking a shell to report whether
it can list it. That read is the one macOS gates, so on a folder the daemon
is refused it could hold the daemon's event loop behind a prompt. It is
awaited now, which leaves the blocking enumerator with no callers.
2026-09-22 01:09:26 -04:00
Jinwoo Hong b9643365ba fix(mobile): the Android audio engine forgets a stop issued while paused (#22132)
* fix(mobile): the Android audio engine forgets a stop issued while paused

A JS toggleRecording(false) arriving while the activity was paused hit the
value == isRecording early return, never reached stopRecording(), and left
isRecordingBeforePause armed, so resumeRecordingAndPlayer() reopened the
microphone with no JS owner (54 minutes on a Galaxy S24, OTA 0.0.51). Every
stop now clears the resume flag and only the pause itself keeps it, via
stopRecording(clearPauseResume), mirroring iOS's
stopRecordingAndPlayer(clearInterruptionResume:); resume consumes the flag
before acting on it.

requestAudioFocus() also overwrote audioFocusRequest without abandoning the
previous one, so every resume left a stale listener on the focus stack and
tearDown() could only abandon the newest. Both paths now go through one
abandonAudioFocus() owner.

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

* fix(mobile): the pre-Q permanent focus loss gives the focus back too (round 1)

CodeRabbit on #22132: on API 21-28 the AUDIOFOCUS_LOSS branch stops recording
and playback for good but kept the focus request, so an idle engine could hold
focus after the other app released it. Q+ pauses and keeps focus to resume.

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

* fix(mobile): a resume never stops a recording it did not pause (round 2)

pullfrog on #22132: with the equality guard gone, resume's toggleRecording(false)
on a cleared flag stopped a recording JS started while the activity was paused,
which a start straddling the permission activity does. Resume now only reopens.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 23:59:04 -04:00
Jinwoo Hong 9243073b73 test(mobile): repin the session page closure at 4,360 after #21705 reached it (#22135)
#21705 added agent-session-option-catalog-antigravity.ts to the option catalog
the session page reaches. It merged beside C2 (#22099), whose pin of 4,359 was
measured before it, so main reads one short. Measured at eb92222e7f and named
by diffing against the closure at 841d06a969.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 23:35:44 -04:00
eb92222e7f feat: support Antigravity as supervised worker (#21705)
* feat: add supervised Antigravity worker support

* fix: address Antigravity worker review findings

* fix: stabilize Antigravity readiness detection

* fix: allow Antigravity resume footer after readiness

* fix(antigravity): make agy reach worker_done as a supervised worker

Three defects each blocked `orchestration worker-start --agent antigravity
--worktree new-child` at the agent_readiness stage.

1. Readiness never fired. The composer check required the trimmed line to be
   exactly one character, but agy 1.2.7 launches in accept-edits mode and paints
   it into the caret row (`> Accept-edits mode: ...`). Widened narrowly to a bare
   `>` or `> <name> mode:`; matching any `> <text>` would make every menu dialog
   read as ready, since they all prefix their highlighted row the same way.

2. No trust artifact for agy. Added markAntigravityWorkspaceTrusted, writing
   ~/.gemini/antigravity-cli/settings.json under `trustedWorkspaces` — verified
   empirically against agy 1.2.7, and distinct from the Gemini CLI's
   trustedFolders.json, which agy does not consult. Trust is exact-path and not
   inherited by subdirectories, so each child worktree needs its own entry.

3. The orchestration path skipped the preset. Orca has two trust dispatch
   chains: the renderer's preflightAgentTrust and the main-process
   markLocalWorktreeTrusted. worker-start only takes the second, which matched
   cursor/copilot/codex and fell through for antigravity, so the trust write
   never happened while renderer-side tests passed.

Verified live end to end: the dispatch settles `succeeded` with worker_done
carrying the right task and dispatch ids, and the worktree is appended to agy's
settings with sibling keys untouched.

Known gap: remote-agent-trust-presets.ts has no antigravity branch. The SSH
artifact path is unverified, so agy over SSH still stalls at agent_readiness.
Recorded in a comment there rather than guessed at.

* fix(antigravity): wire trust preset through preload safely

* fix: preserve Antigravity readiness across transcript tails

---------

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: LielinaH <lielinah@gmail.com>
2026-09-21 20:22:08 -07:00
Jinwoo Hong 841d06a969 feat(mobile): the rich Markdown editor mounts on the page (OTA phase C, C7.10 C2) (#22099)
* feat(mobile): the editor document reads its surface from its host's root

The markup gives the editable surface an id, and inside the WebView that is
unambiguous because the document is the page. On the page it is not: a stack
transition keeps the outgoing session screen mounted while the incoming one
starts, so two hosts carry `#editor` at once and a page-wide `getElementById`
hands both documents the first one. The seventh seam is the root, exactly as it
is the terminal's ninth (ruling 24): the WebView names none of them and gets the
whole page, the page names the element its mount planted the markup in.

Red first, `vitest run src/components/rich-markdown/document-host-root.test.ts`
against the page-wide read: 4 failed, 1 passed — content written into the second
host landed in the first, both documents serialized the first surface, an edit in
the second reported through the first document's host, and stopping the first
took the listeners off the surface the second was still using. The one that
passed is the control: a document with no root still reads the whole page, which
is what the WebView gets.

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

* feat(mobile): hold the editor's document rules under its host element

The editor's sheet says `:root`, `*`, `html` and `body` because inside the
WebView it owns the page. Appended to the head of a React Native Web application
all four restyle every screen the shell can show, so the page mount may inject
only what it owns — ruling 19's rule for `window.onerror`, applied to CSS.

The terminal's half of the scoper drops those rules and repaints through a seam,
because the colour `html, body` was setting belongs to the application. The
editor has no such seam and needs none: its host element *is* that editor's page,
so `scopeDocumentStyleToHost` moves the document's own rules onto the host — the
variables every other rule reads, the surface colour, the font, the box model —
and everything else hangs under it. A selector that merely starts at the document
(`body p`) throws rather than being rewritten into something it did not say.

Red first, `vitest run src/components/rich-markdown/page-stylesheet.test.ts`:
6 failed, 0 passed, all on the absent export.

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

* refactor(mobile): the fifteen toolbar commands are one row both surfaces render

The row of controls is not the WebView's: a press becomes an injected
`runCommand` there and a call on the page, and neither difference belongs in the
toolbar. Extracted so the page's editor does not declare fifteen rows of its own
that would drift from the phone's.

`MobileRichMarkdownToolbar.test.tsx` adds the fence a second copy would have
needed: the row names every command in the contract, exactly once. Verified red
by dropping `codeBlock` from the row — "names every command in the contract,
once" failed on the 14-member list before the case went back. The native
component's own test and the web fallbacks file stay green unchanged, which is
what says the extraction moved nothing.

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

* test(mobile): keep the toolbar test inside the tests-typecheck ratchet

`check-tests-typecheck-ratchet.mjs` reported the new file as newly failing
`tsc -p tsconfig.test.json`: the `ScrollView` mock's spread did not match any
`createElement` overload, and comparing a node's `ElementType` against the string
`'Pressable'` is a no-overlap comparison. Host strings for the mock and
`String(node.type)` for the read, rather than a cast. Ratchet back to OK at 800
files.

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

* feat(mobile): the rich Markdown editor mounts on the page

`react-native-webview` renders nothing in a browser, so C7.6 gave the page a
plain Markdown field and recorded the toolbar and the rendered view as a
degradation. Ruling 26 makes that debt rather than done: the page mounts the
document itself. `rich-markdown-web-document-mount.ts` is the editor's half of
what `terminal-web-document-mount.ts` does for the terminal — the sheet held
under the host's class, the markup planted in the host, one factory call, and a
dispose that gives the host back. `MobileRichMarkdownEditor.web.tsx` is the
component over it, with the same fifteen-command toolbar and the same controller
the phone uses, so `MarkdownReader` cannot tell which sibling it has.

Three seams are the page's rather than the window's. Messages reach
`handleMessage` directly and never `window.ReactNativeWebView`, which on the page
is the shell's bridge. The URL for Link and Image comes from `TextInputModal`:
`window.prompt` was measured to return null in both shells, so those two commands
silently did nothing. And no inset source is supplied, so `onKeyboardInsetChange`
is never called — the screen's `keyboard-occlusion.web.ts` measures the same
viewport with the same formula, and a report here would lift its bar twice.

Red first, two runs. `rich-markdown-web-document-mount.test.ts`: 9 failed on the
absent module, and its listener case is the one that holds ruling 21 — a second
mount reports its own edits and the first mount's detached surface reports
nothing, with an event dispatched on it to say so. The four new cases in
`mobile-webview-editor-web-fallbacks.test.tsx`, run against the plain field still
in the tree: 4 failed, 6 passed — no toolbar, no URL modal, and the `TextInput`
the page is meant to have lost.

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

* fix(mobile): put the editor's surface on the 16 px floor, and grow a census that can see it

`#editor` computed to 14 px, measured in both engines. iOS zooms the page on
focus of any editable under 16 px and does not zoom back, and
`keyboard-occlusion.web.ts` answers 0 for the rest of the session at a scale
other than 1 — the exact failure the floor exists for, on the page's only
full-screen writing surface. The size now comes from the text-input seam, which
is also where the two hosts part: the phone keeps the app's body size because a
WebView has no page to zoom, the page gets the raise, and one binding moves both
if the floor ever does.

The `TextInput` census could not have caught it. `modulesDeclaringTextInput`
matches JSX tags and `style` props, and this is a `contenteditable` in a markup
string sized by a rule in a stylesheet. `mobile-web-app-editable-host-font-size.mjs`
starts from the markup instead: it finds every editable host a closure declares,
follows its id to the rule beside it, and reads the size the same way — a literal
at or above the floor, or the seam's own export imported from the seam's module.
An editable with no id, or one no sibling sheet styles, is reported unresolved
rather than passed.

Red first. The rule's own file reported
`src/components/rich-markdown/document-style.ts:36` as the offender before the
fix (4 failed, 3 passed on the first run, the other three being the brace scanner
and the line-start anchor the fixtures found). The closure case in
`mobile-web-app-session-terminal-closure.test.mjs` now names the editor as the
one editable in the session route's closure and its offender list is empty:
1 passed, 4 skipped under `-t editables`.

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

* fix(mobile): the caret survives the host's URL dialog, so Link and Image insert

Measured in the render check, on both engines: the Link and Image commands opened
the modal, took the URL, and inserted nothing. The dialog is what takes the
caret — the modal focuses its own field — and `execCommand` on a document that
does not hold the selection does nothing at all. So the page had swapped one
silent failure for another: `window.prompt` returning null on the phone, and a
command with no selection on the page.

Two halves. The document remembers its caret before it waits and puts it back
after (`restoreRememberedSelection`, unconditional where `restoreSelectionOrEnd`
needs a flag, because the wait itself is the blur); if the host replaced the
content while the dialog was open, the remembered range is gone from the document
and the caret goes to the end instead. And the component answers the promise from
the drawer's `onAfterClose` rather than from the submit, because WebKit would not
take the focus back while the field still held it — with the answer released on
submit, chromium inserted and WebKit did not.

`TextInputModal` forwards `onAfterClose` for that, which is the one thing it did
not already pass through to `BottomDrawer`.

Red first, `editor-selection.test.ts` against the previous `editor-commands.ts`:
2 failed, 7 passed — the caret was left in the dialog's field, and a replaced
document did not fall back to the end. The render check's Link/Image case went
from failing on both engines to inserting on both, with the inserted image's
`naturalWidth` above zero under the shipped policy.

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

* test(mobile): the page's rich Markdown editor, in both engines under the shipped header

`config/scripts/mobile-web-app-rich-markdown-render.test.mjs`: the real component,
mounted by the real React, driven through its toolbar in chromium and webkit under
the policy read out of the shell's own Kotlin source. Sixteen cases, eight per
engine.

What it measures rather than asserts: all fifteen commands change the document,
each with the precondition that what it produces was not there first; the surface
computes to the 16 px floor and the document's own `--editor-surface` variable is
set on the host and nowhere on the root element; `ready` and `change` cross the
seam while `window.ReactNativeWebView` — defined by the rig so its absence is a
reading — is never touched; Link and Image are answered by the modal, and the
inserted image paints with a non-zero `naturalWidth`; one change per checkbox tap
and one per inline code; a link tap reaches the host instead of navigating; a
remount leaves the listener snapshot and the scheduler exactly where one whole
cycle left them (rulings 20 and 21); and two editors on one page hold their own
content and report their own edits.

Four harness facts the first runs found, each now in a comment: the entry needs
four of `MOBILE_WEB_APP_SHIMS` (`isFabric` threw `global is not defined` and every
case failed at `data-ready`); `.web.jsx` in `resolveExtensions` or
`react-native-svg` resolves its Fabric components; a `SafeAreaProvider`, which the
route's navigator supplies and a bare mount does not; and the document's markup,
not its text, as the oracle for a content reset — `### body text here` and `body
text here` read the same, so a text wait passed on the document it was replacing.

One finding, reported not fixed: WebKit's `insertUnorderedList` nests the `<ul>`
inside the `<p>` it was given and the serializer walks back out with the same
text, so a bullet list does not survive a round trip there. The phone's WebView is
the same engine, so this is not something the page introduces; the case names the
command's own element and the reset is numbered per command to work around it.

Run 5 of 5: 16 passed, 0 failed, 0 errors, exit 0, 6.58s.

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

* test(mobile): repin the session route's closure for the editor on the page

Both sides measured with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base
`9267423f22`, all five postinstall generators run first, the before side a scratch
worktree detached at that sha:

  modules        4333 -> 4360   (+27)
  local modules   991 -> 1018   (+27)

All 27 are local and none is vendored, which is the point: the editor is the app's
own code, not a library. The document's 24 modules under `src/components/rich-markdown/`
were reachable from nothing on the page while it rendered a plain field, and the
other three are the mount, the shared toolbar, and the controller with its
keyboard-inset module. Nothing leaves, because the web sibling replaces its own
native file and that file was never in this closure. Named by diffing the two
`local` lists, not inferred from the total.

`document-style-scoping.ts` is on both sides: the terminal's mount already brings
it, so the editor's second export costs no module.

The generation, measured the same way on both sides: 8,028,418 -> 8,056,166 bytes
(+27,748) across 109 assets against the 9 MiB ceiling, 85.1% -> 85.4%. The script
count does not move (67 against the 76 the chunk fence allows for 15 routes) and
neither does the entry's static closure (1,612,052 bytes against 3 MiB) — this is
code the route already reached for, not a new chunk boundary.

The grant census needs nothing: `openExternalLink` is the editor's only seam with a
grant behind it, and the session route already declares `externalLink` for six
other openers. `mobile-web-app-page-grant-call-sites.test.mjs` passes unchanged.

Closure, webview-consumer and grant censuses together: 28 passed, 0 failed, exit 0.

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

* fix(mobile): drop an oxlint directive the changed-code gate reads as unused

`check-changed-code-quality.mjs` failed with one finding: the mount effect's
`react-hooks/exhaustive-deps` disable reports no problem under that config, so the
directive itself is the finding. The reason it carried is worth keeping and now
reads as a plain comment — the effect mounts once, with `promptForUrl` taken from
the closure, because re-running it would throw away a live document and the caret
in it.

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

* fix(config): the editable-host census counts every editable tag, not every id

CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:50`, and right on the
code: the pattern started from `id="…"`, so it matched only hosts that carry one.
The no-id guard fired for a file with *no* named host at all, which means a file
holding a named host beside an anonymous one reported the named one as clean and
said nothing about the other. An editable is its tag; the id is read out of the
tag afterwards.

Also `:145`, also right: the sibling search was `startsWith(directory + '/')`,
which reaches the subtree, and the walk stops at the first file whose sheet opens
the host's selector. The closure's order is the bundler's rather than
alphabetical, so a sheet one directory down could answer for the sibling the host
actually gets. Now the immediate directory only.

Red first, both cases in the census's own file. The mixed fixture reported one
host where two were planted (1 failed, 7 passed); the nested fixture, with the
nested sheet first in the closure and a compliant 18 px rule in it, hid a 14 px
sibling and reported no offender (1 failed, 8 passed). 9 passed after.

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

* fix(config): an editable with no declared size is unresolved, not a pass

CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:110`, and right for the
CSS case: `readFontSize` answered `onSeam: true` for a rule that declares no
`font-size`, so the offender check accepted the host without being able to say
what size it gets. The inherited value comes from a rule this walk does not read —
the host element's own, or the page's root — and it can be 14 px.

So "no declaration" becomes "cannot say" and lands in
`unresolvedEditableHostStyles`, which the session closure census holds at empty.
Not an offender: an offender is a size this walk read and found under the floor.

The `TextInput` half of the seam still lets an absent `fontSize` through as
inheritance. That is main's policy and it is about a prop rather than a cascade, so
it is not touched here; the divergence is stated in the reader's own comment.

Red first: the inheritance fixture reported no unresolved host where the size is
unknowable (1 failed, 8 passed), 9 passed after. The real tree is unaffected —
the editor declares its size on the seam — and the closure census still reads an
empty unresolved list.

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

* fix(config): the editable-host census reads the font-size the cascade uses

CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:119`, and right: the walk
read the first `font-size` in a rule, and CSS takes the last of equal importance.
`font-size: 16px; font-size: 14px;` was therefore reported compliant for a surface
the browser renders at 14 px. `!important` outranks every declaration that is not,
whatever the order.

The flag is also stripped from the value, which the finding did not name but the
fixture caught: without that, a compliant size carrying `!important` was reported
as an offender, because it matched neither the literal nor the substitution shape.

The declarations are split on the separator rather than matched with a value
pattern. A pattern excluding `}` cut `${TEXT_INPUT_FONT_SIZE}px` at the brace of
its own interpolation and reported the real editor as an offender — caught on the
first run of the fix, and the reason the split is the shape here.

Red first: 2 failed, 9 passed — the repeated-declaration fixture reported no
offender, and the important-declaration pair reported the wrong one of the two.
11 passed after.

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

* test(config): the render check reads the floor from the seam instead of retyping it

pullfrog, `mobile-web-app-rich-markdown-render.test.mjs:37`, and right: the comment
said the floor was read from the seam and the constant was the literal `16`, which
is the shape the seam exists to prevent. It now comes from
`textInputFontSizeFloor(mobileDir)`, the same reader the closure census uses, which
throws rather than defaulting when the seam is gone.

The assertion becomes "at or above the floor" rather than equal to it. The seam is
`Math.max(bodySize, floor)`, so a theme raising the body size past the floor raises
what the page computes; equality against the floor would have been the same stale
literal one module further away.

Two controls, both run. Raising `TEXT_INPUT_FONT_SIZE_FLOOR` to 18 in the seam
keeps the case green on both engines, because the stylesheet reads the same module
and the page computed 18 — the two moving together is the point. Replacing the
stylesheet's `${TEXT_INPUT_FONT_SIZE}px` with a literal `14px` reds it on both
engines, `expected 14 to be greater than or equal to 16`, which is what says the
assertion carries weight. Both files were restored.

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

* fix(mobile): the editable-host census reads every rule that sizes the host (round 3)

`ruleFor` returned the first exact `#id` rule and the walk stopped there, so a
later exact rule of equal specificity, or a higher-specificity subject rule that
still targets the host, could lower the rendered size unseen.

Every exact rule in the sheet is now collected in source order and read as one
cascade, and any other rule whose subject compound targets the host and declares
`font-size` makes the host unresolved rather than compliant. No specificity
arithmetic, and the sibling walk is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 22:54:32 -04:00
Jinwoo Hong 197550c952 test(mobile): repin the session page closure at 4,333 after #18790 reached it (#22119)
#18790 added the freebuff agent icon to mobile-agent-icon-assets.ts, which the
session page reaches. It merged between #22114's closure measurement (4,332)
and its merge, so main pins one module short. Measured on main's tip
059ee59a48 and named by diffing the closure at 226f4a0775 against it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 20:59:27 -04:00
Jinwoo Hong 059ee59a48 chore(mobile): repin the recording corpus to main's tip after #22111 (#22118)
#22111 re-recorded the diff-notes goldens with baseline at its branch
commit f9d4822204, which the squash left unreachable from main. Bumped to
main's tip 0b1567a7b1 and re-recorded: the diff is the baseline header in
787 goldens and the manifest's baseline line, nothing else, so the
recordings are identical.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 20:32:32 -04:00
Jinwoo Hong 0b1567a7b1 fix(mobile): catch the diff-comments loader rejection at the effect (OTA phase C follow-up) (#22111)
* fix(mobile): catch the diff-comments loader rejection at the effect

`use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` with no
catch, so a *rejected* `worktree.show` raised an unhandled rejection on every
session mount: a document-level error, not a page fault, and a red herring in
crash reports and device proofs. The catch goes at the effect rather than inside
the loader, whose promise the recording adapter awaits.

`config/scripts/mobile-web-app-session-render.test.mjs` pinned the page's error
list to exactly that one rejection; it is now the empty list, which is what
makes the browser proof notice the fix.

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

* test(mobile): re-record goldens without the diff-comments rejection

The corpus certified the unhandled rejection the commit before removes, so the
golden had to move with it. Scoped re-record: `baseline` bumped by editing that
one line, then `--record`.

Every diff line classified:

- `baseline`, 788 lines (787 goldens + `pilot-scenarios.json`), and nothing else
  in 786 of them.
- `matrix-session.diff-notes-worktree.show-1.json`, the only golden with a
  substantive change: three `unhandled-rejection` effects leave the pool
  (`da8252771fbd` incompatible_reply, `d638e32b9559` transport failure,
  `cf4fa55e3a8d` empty) and the four checkpoints that carried them now read
  `"effects": []`. No renumbering; no other effect key moved.
- `HEAD_EFFECT_SHA256` to the measured `cc25f370…ebe522`. The 24-effect count is
  unchanged.

`recorderSha256`, `lockfileSha256` and `adapterSha256` all hold.

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

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 20:26:54 -04:00
Jinwoo Hong 226f4a0775 fix(mobile): two more table parsers hold a pipe in a cell (OTA phase C follow-up) (#22114)
* test(mobile): pin escaped pipes in mobile markdown table cells

The mobile preview parser splits a table row on every pipe, so a cell
that escaped one becomes two cells and keeps the backslash.

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

* fix(mobile): read table rows through the shared row splitter

The editor's markdown-table-rows already splits on unescaped pipes only
and unescapes the cell; it has no imports of its own, so owning the rule
once costs the preview parser nothing.

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

* test(mobile): pin escaped-pipe rows in PR comment tables

Its splitter strips the trailing pipe before walking escapes and reads
`\\|` as an escaped pipe, so a row ending in `\|` loses the pipe and a
cell holding a backslash swallows the separator after it.

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

* fix(mobile): split PR comment table rows on unescaped pipes only

Its own delimiter grammar stays local: a single dash still opens a table
here, which the editor's three-dash separator would reject.

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

* test(config): repin the session route closure at 4,332 modules

markdown-table-rows.ts joins through the PR comment renderer. Measured on
this head: 4,332 modules / 990 local, and it is the only file under
rich-markdown/ in the closure, so nothing came with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 20:26:41 -04:00
0677271709 fix(orchestration): reap leaked worker terminals via process-incarnation fallback — stops an unbounded PTY/process leak on Remote Server (OOM / cgroup PID exhaustion) (#18790)
* fix(orchestration): remint live handle from process incarnation on worker release

When a durable terminal handle goes stale (rendererGraphEpoch fence),
inspectWorkerTerminal re-mints a live handle via
resolveTerminalHandleByProcessIncarnation + matchesProcessIncarnation so
release/stop/read act on the still-running PTY instead of reporting
missing and leaking the agent process tree.

- keep main shared host-scope re-exports; add matchesProcessIncarnation
- wire observation.terminalHandle through control/stop/release
- rebuild release-completion on main structured paths
- on missing/unattached + provably exited: settleDead fence first, then
  same-incarnation settleWorker fall back (archive may block settleDead
  mid-request); settle before recovery defer

* fix(orchestration): derive SSH host scope from the reminted handle; reuse fresh-request recovery guidance for structured workers

Addresses two open CodeRabbit review comments on PR #18790.

inspectWorkerTerminal read the dispatch authority with the stale durable
terminalHandle, so after a remint the lookup resolved nowhere and
currentHostScope was always undefined — an SSH worker with no liveness
verdict and no persisted host_scope got classified from terminal.connected
instead of unverifiable. It now reads the same effectiveHandle every other
observation in the function uses.

stopStructuredWorkerForRelease told the caller to repeat the release with
the same --retry-request, which only replays the stale release_unknown
receipt and made a structured-worker close failure permanently unretryable.
It now sources releaseUnknownRecovery from worker-release-completion so the
fresh-request-ID guidance lives in one place.

Pre-commit lint-staged (oxlint + oxfmt) run manually: clean.

* test(orchestration): exercise incarnation recovery through runtime paths

* test(orchestration): pin the incarnation read scenario to the reminted terminal

The read scenario only asserted that the call resolved, so it documented
nothing about which handle the read reached. Assert that the handle
readTerminal received resolves to the registered pane and incarnation, so
the scenario proves the read went through the reminted terminal instead of
passing on the incarnation fence's throw.

* refactor(orchestration): drop redundant incarnation prefix check; require liveTerminalHandle

* feat: add freebuff as a first-class TUI agent (#42)

<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every
commit. -->

| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 0 | 0 | 0 | 0 |
| Prod | 28 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$​37 | 0 |
$\color{#1a7f37}{\Huge{\mathbf{+}}}$​37 |

<!-- /orca-pr-loc -->

## ELI5

Add Freebuff (`freebuff`) as a recognized first-class TUI coding agent
in Orca alongside Codebuff and other supported agents.

## What Changed

- Registered `freebuff` across shared TUI agent definitions,
configuration catalogs, display names, and telemetry schemas.
- Added agent icons, favicons, status mappings, and mobile asset
references for Freebuff.
- Added localization strings across supported language packs (`en`,
`es`, `fr`, `ja`, `ko`, `zh`) and updated locale translation policy.
- Documented Freebuff CLI in README agent table (`npm i -g freebuff`).

## Why

Freebuff is a CLI coding agent twin of Codebuff (`npm i -g freebuff`).
Adding it to the catalog enables users to launch worktrees, run
automated sessions, and pick Freebuff directly within Orca.

## Linked Issue

N/A

## Visual Proof

`N/A` - Catalog registration and metadata definition for CLI agent
launch; UI rendering uses existing TUI agent picker and status
components.

## Testing

- Verified TypeScript contracts, schemas, and catalog configurations.
- Tested CLI detection / agent picker integration locally on Linux
(`worktree create --agent freebuff`).

## AI Disclosure

Assisted by AI coding tooling.

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why (including ELI5)
- [x] Before/after screenshots or videos attached for UI changes, or
`N/A` with reason
- [x] Self-reviewed for correctness, security, and performance
- [x] Cross-platform, SSH/remote, and path/shortcut impact considered
(or N/A)

---------

Co-authored-by: Lesley Murfin <lesley@revivebusiness.ca>

* test(orchestration): erase method overloads in worker reap fixtures

* test: document worker fixture type boundaries

* test: simplify worker fixture typing

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: svc-orca[bot] <313947298+svc-orca[bot]@users.noreply.github.com>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-21 17:23:33 -07:00
88f2f01061 fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY (#19430)
* fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY

Root cause: daemon-launched-child.ts forks the detached terminal daemon with
detached: true, which escapes the POSIX process group (setsid) but never the
systemd cgroup. Every PTY the daemon owns is itself an undetached direct
child of the daemon (native-pty-spawn.ts). Under a combined systemd unit
(Type=simple, KillMode=mixed, per docs/reference/headless-linux-server.md),
a systemctl restart/stop SIGKILLs every process still in the cgroup at the
stop timeout -- the daemon and every live terminal -- even though the
codebase already has a fully-built adoption/reattachment path for a
surviving daemon (orcad-entry.ts's refreshRestoredOrchestrationAuthority +
reconcileLegacyWorkerTerminals, gated on daemonOwnsFreshPersistentPtys()).
That path never fires today because the daemon never survives long enough.

Fix: when systemd is actually supervising the process and the OS user has a
reachable systemd --user manager (isDurableDaemonScopeSupported(), Linux
only), launch the daemon via systemd-run --user --scope so it lands in a
cgroup that is a sibling of the service unit's cgroup, not a descendant of
it. A systemctl restart of the combined unit then never reaches it. Any
failure of the scoped launch (no reachable bus, D-Bus policy rejection,
etc.) falls back transparently to the existing plain fork() launch, so
every platform/environment without this capability is unaffected.

The daemon self-detects its own resulting cgroup scope via /proc/self/cgroup
(detectOwnCgroupScopeUnit()) rather than trusting the launcher's intent, and
publishes it as cgroupUnit in its pid record and orcad's health/readiness
payload (health.terminalDaemon.cgroupUnit), so a running deployment can be
observed to confirm the fix actually engaged.

No new session registry is added: the existing daemon pid-record + adoption
protocol (publishDaemonPidFile, daemon-pid-record-quarantine.ts's
dead-record reclaim, refreshRestoredOrchestrationAuthority) already
implements durable, crash-safe reattachment for a surviving daemon -- it
was simply never exercised against a full unit restart before now.

Proven via a systemd-in-Docker recovery test: a live PTY session's shell
process, its daemon, and the daemon's cgroup scope were all confirmed
unchanged across a real systemctl restart of a Type=simple/KillMode=mixed
unit, while the main process pid changed (confirming the unit actually
restarted) and the new process's health payload recognized the surviving
daemon as adopted and live. A fresh write into the same PTY post-restart
reached the same running shell. Ordinary terminal create/work/release and
the #18789/#18790 worker-release reap-fix regression tests are unaffected.

Fixes stablyai/orca#19408

* fix(daemon): probe the real per-UID XDG_RUNTIME_DIR before trusting the process's own env

isDurableDaemonScopeSupported()/buildDurableDaemonScopeCommand() trusted the current
process's own XDG_RUNTIME_DIR env var first, falling back to /run/user/<uid> only when
that var was unset entirely. On mtl-02, orca-serve@factory.service's RuntimeDirectory=
hardening directive makes systemd export XDG_RUNTIME_DIR=/run/orca_serve/factory into the
unit's process -- a private scratch dir that shares the env var's name but has nothing to
do with the user session bus. /proc/<pid>/environ on that host confirmed exactly that path
plus DBUS_SESSION_BUS_ADDRESS=disabled:, while the real bus was reachable the whole time at
/run/user/985 (confirmed via systemctl --user is-system-running with that dir exported by
hand). The probe treated the hardened override as authoritative, found no bus socket there,
and reported unsupported on every launch -- so the cgroup-escape fix from #19408/#19430
never actually engaged on real hardware, even though tonight's factory deployment picked it
up.

Fix: resolveUserRuntimeDir() now always tries the conventional /run/user/<uid> path first
(computed independently via getuid(), never trusted from env), checking for a genuinely
connectable bus socket via statSync(...).isSocket() rather than a bare existsSync. It falls
back to the process's own XDG_RUNTIME_DIR only when that canonical path has no reachable
bus -- covering hosts that legitimately have no /run/user/<uid> at all but do have a
working bus wherever their own environment points. buildDurableDaemonScopeCommand() now
explicitly sets XDG_RUNTIME_DIR to whichever path this resolution picked, rather than
inheriting the spread env's (possibly hardened-wrong) value.

Both isDurableDaemonScopeSupported() and buildDurableDaemonScopeCommand() gained an
injectable canonicalRuntimeDir parameter (defaulting to the real computed path) so tests
can exercise the hardened-override scenario deterministically with a real, connectable
AF_UNIX socket fixture instead of the live host's actual runtime directory.

Docker's stock jrei/systemd-ubuntu test container never had this hardening directive, so
this gap was structurally invisible to the container-based verification in #19430 -- only
caught against real mtl-02 hardware.

* fix(daemon): report the daemon's own pid over the ready handshake, not systemd-run's

The launcher used to infer the daemon's identity pid from the immediate
spawned child (`child.pid`). On the durable-scope path that child is
`systemd-run --user --scope`, not the daemon, so the launcher was asserting
an identity it had no authority over.

`DaemonReadyIdentity` now carries a required `pid` populated from
`process.pid` inside the daemon itself, and `daemon-launched-child.ts` takes
`launchedIdentity.pid` from that self-report. Both sides of the
`holdDaemonAdoptionLease` pid comparison therefore originate inside the
daemon process, which is the idiom this branch already uses for cgroup
membership (`detectOwnCgroupScopeUnit` reads `/proc/self/cgroup` rather than
trusting what the launcher intended).

Note on the reported consequence: `systemd-run --scope` registers its *own*
pid on the transient scope unit and then `execvpe()`s the target command --
same pid, no intermediate process -- so adoption did not in fact fail on
systemd >= 206 (verified against systemd 255.4-1ubuntu8.17 and current main,
`src/run/run.c` `start_transient_scope()`). The fix stands on its own merits:
it removes a silent dependency on that exec-vs-fork implementation detail,
which a `systemd-run` shim earlier in PATH or any future systemd change would
have broken with no diagnostic.

`terminateLaunchedDaemonChild` was audited and deliberately left on
`child.pid`: for the same execve-preserves-pid reason that pid is either
still systemd-run mid-scope-setup (killing it correctly aborts the launch) or
already the daemon, so it targets the right process either way.

Regression coverage: `daemon-launched-child-identity.test.ts` pins the
identity source, and `daemon-ready-identity.test.ts` gains pid-validation
cases. Ready-message fixtures across the `daemon-init-*` suites were updated
for the now-mandatory field.

Addresses:
https://github.com/stablyai/orca/pull/19430#discussion_r3953722704
https://github.com/stablyai/orca/pull/19430#discussion_r3954346518

* test(daemon): assert cgroupUnit in the pid-file parse contract

`parseDaemonPidFile` returns `cgroupUnit` on every branch as of the
durable-scope commit on this branch, but five exhaustive `toEqual`
assertions in daemon-health.test.ts still described the pre-scope shape, so
they failed on the branch independently of any later change.

Adds the field to those expectations. Deliberately not relaxed to
`toMatchObject`: asserting the full parsed shape is what makes these tests
catch a field silently dropped from the pid-file contract.

* refactor(daemon): resolve the canonical user runtime dir at one point

The per-UID path cannot change for a live process, so compute it once into a module
const instead of threading the same default call through three signatures, and drop
the try/catch around a getuid() that cannot throw once it exists. Trims the module
prose to the non-obvious facts and corrects the pid-file record comment: an unscoped
daemon writes null; only records no daemon wrote are absent.

* test(daemon): clean up the cgroup-scope fixtures and assert a verdict

The cgroup fixture tracked only the file it wrote, leaking one temp dir per case.
Drains both fixture lists with splice so the pop-may-be-undefined guards go away,
and replaces a not-throw/typeof-boolean pair with the verdict it was circling:
no resolvable runtime dir means unsupported.

* refactor(daemon): share the detached child options across both launch paths

cwd, detached and stdio were repeated in the fork and systemd-run branches, which
left the two comments explaining them hovering over the env block instead. Names
them once so each branch carries only its own delta.

* refactor(daemon): validate the ready pid like every other field

typeof-first narrows the value, so the two 'as number' casts the isSafeInteger check
needed disappear and the pid guard reads like the startedAtMs guard below it.

* fix(daemon): don't retry the launch unscoped after losing the endpoint race

A scoped attempt that lost the endpoint to another daemon was retried unscoped: a
second doomed fork, a misleading 'cgroup-scope launch failed' warning, and the same
DaemonEndpointUnavailableError the caller was already going to adopt on. Rethrows it
instead, since no launch mode can win a race that is already lost.

Also drops a private alias for DaemonChildSpawnOptions and the two 'as number' casts
on child.pid in the startup-failure cleanup.

* fix(daemon): unlink the pid record by the pid the daemon published

The record holds the daemon's self-reported pid, so match on that rather than on the
immediate child's, which is the systemd-run wrapper's until it execs.

* fix(daemon): route the scope launch through the child-process chokepoint

The two files this PR added imported `node:child_process` directly, which
`child-process-import-boundary.test.ts` fails on deterministically: the
offender count went 155 -> 157 against a pin of exactly 155. Raising the pin
or listing the files is what that test explicitly forbids, and the allowlist's
own note says a split "moved the import, it did not add one" -- so the fix is
to get both new files off the module and put the count back at 155.

- `daemon-cgroup-scope.ts`: the `systemd-run --version` probe now uses
  `runProcessSync` instead of `execFileSync`, so it gets the shared spawn
  decisions. Kept synchronous deliberately: `launchDaemonChild` attaches the
  readiness listener in the same tick it is called, and an await before the
  spawn moves the child past that tick. A non-zero exit is data rather than a
  throw here, so the verdict now checks `code === 0 && !timedOut`.
- `daemon-launched-child-spawn.ts`: the scoped launch uses `spawnProcess`, and
  the long-standing unscoped launch keeps `fork` semantics through a new
  `forkProcess`.
- `src/shared/child-process/fork-process.ts`: the fork arm of the chokepoint.
  `spawnProcess` cannot express a Node child with an IPC channel started from
  a module path under an overridden `execPath`, and the existing launch tests
  are written against `fork`'s contract, so a spawn rewrite would have changed
  module resolution, `execPath` and `execArgv` at once. It passes
  `windowsHide: true` -- the flag every other call site in that directory
  sets, reachable via an assertion because `ForkOptions` omits it -- which
  keeps `windows-console-visibility.test.ts` at its pin of 65 too.

Both ratchets pass with both pins and both allowlists untouched.

Docs: `orcad-operations.md` and `headless-linux-server.md` still described the
limitation this PR removes as permanent. Both now describe the durable-scope
survival path and its preconditions (systemd as PID 1, a reachable user bus /
`loginctl enable-linger`, `systemd-run` on PATH), and scope the old text to
the unscoped-fallback case, pointing at `health.terminalDaemon.cgroupUnit` as
the way to tell the two apart on a running host.

* fix(daemon): seal the cgroup capability probe from the host and correct KillMode=mixed docs

The capability probe consulted the host's own /run/systemd/system marker and
spawned the real systemd-run binary, so the hermetic unit tests could only pass
on a systemd host (and fail closed otherwise, even with faked bus sockets).

- Thread systemdBootPath and runVersionProbe as test seams through
  isDurableDaemonScopeSupported, defaulting to the real boot marker and
  systemd-run --version probe in production.
- Narrow the injected probe to the ProcessResult slice it consumes.
- Cover: no-systemd-boot, non-zero probe exit, and probe-timeout cases.
- Correct KillMode=mixed semantics in the docs: the cgroup-wide SIGKILL fires
  the instant the main process exits, not after TimeoutStopSec; document the
  Docker-container caveat and add KillMode=mixed to the multi-service template.

* fix(daemon): satisfy assertion checks in scoped launch

* fix(daemon): satisfy anti-slop and console guards

* test(serve): update shutdown docs assertions for daemon scope

* fix(daemon): migrate adopted legacy scopes

* docs: qualify restart safety by daemon scope

* docs(daemon): qualify Upgrade restart prose with durable scope caveat

Align the Upgrade section in docs/reference/headless-linux-server.md with
the earlier preservation section and docs/reference/orcad-operations.md:
a service restart terminates live processes only when running under the
unscoped fallback, and stops should be treated as destructive unless
health.terminalDaemon.cgroupUnit names an orca-daemon-*.scope.

Update the shutdown workflow test assertion in
config/scripts/headless-serve-shutdown-workflow.test.mjs to match.

* fix(daemon): harden legacy scope migration

---------

Co-authored-by: Lesley Murfin <260182349+LesleyMurfin@users.noreply.github.com>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-21 17:23:30 -07:00
Jinwoo Hong e1c8df41e5 fix(mobile): declare externalLink on the two page routes that reach the protocol wall (OTA phase C follow-up) (#22113)
* test(mobile): hold every page route to the externalLink call site it reaches

The grant call-site census carried an exact allowance for the two routes
that reach the shared protocol wall's `openExternalLink` without
declaring `externalLink`. Removing it makes the census enforce the
declaration instead of recording the gap; it now names both routes.

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

* fix(mobile): declare externalLink on the worktree list and agent history

Both routes render the shared layout's `HostProtocolGate`, whose
`ProtocolBlockScreen` opens its Update Orca link through
`openExternalLink`, and neither declared the grant: the tap posted a
notify the shell refuses, with nothing on screen. The census measures one
call site in each closure, `src/components/ProtocolBlockScreen.tsx`.

Repinned by measurement, with the manifest change named: the route-list
pin, and the handed-off hop census, which goes 23 rows to 19. The four
rows that leave are these two routes into the explorer and its preview —
all four now declare the same four grants, so a tapped file stays in the
document instead of costing a native frame and a second bridge session. A
new case asserts that coverage, so the four absences are load-bearing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 20:20:44 -04:00
Brennan Benson 6ce7208b98 fix(sidebar): stop a workspace with a structured chat reading as asleep (#22098)
* fix(sidebar): stop a workspace with a structured chat reading as asleep

A workspace whose only surface is a structured native chat showed the sleeping
moon, lost its pull-request glyph to it, and would vanish entirely under the
hide-sleeping filter.

hasActiveWorkspaceActivity asked three terminal-shaped questions: a tab in
tabsByWorktree with a live PTY, a browser tab, or a fresh non-done agent-status
row. A structured chat answers none of them. Its tab lives in
unifiedTabsByWorktree, so the PTY term never sees it, and an idle session
projects state 'done', which is exactly what isFreshNonDoneAgentStatus refuses.
Both chats finishing their turn was enough to draw the moon.

Add a fourth term keyed on the chat EXISTING. Not on a live provider child: that
child is held only while the chat's pane is visible and is evicted 15s after it
is not, so keying on it would flip the glyph on every worktree switch and report
a process recycle the user never sees. The transcript, and the session's ability
to take the next send, outlive the child.

The term goes in the shared predicate, not the card, because the moon, the
hide-sleeping filter and the Cmd+J palette all read it and must not disagree
about which workspaces are asleep.

Supporting moves, no behaviour change: the projection sits beside its siblings in
visible-worktree-activity-inputs, and buildVisibleWorktreeOptionsFromState moves
to its own module, which leaves the filter a pure function of its options and
keeps both files under the 300-line cap without raising it.

* fix(sidebar): keep structured chats visible across workspace surfaces

* refactor(sidebar): keep jump palette inputs below lint limit
2026-09-21 17:15:43 -07:00
Brennan Benson 15472cd4c6 feat(native-chat): keep restart recovery available in status bar (#21397)
* feat(native-chat): keep restart recovery available in status bar

* fix(native-chat): source the restart offer from the host and retire it on recovery

Closing the reconnect dialog spent the durable recovery offer, so looking around
before deciding lost the recovery for good. The offer now survives a close, and
the status bar carries it — but a durable offer needs a way to die, and it only
had a reconnect, an explicit dismiss, and a 24h expiry.

The claim's launch-scoped lifecycle moves into its own collaborator, which splits
what the host ADVERTISES from the evidence it holds. A resume-capable hold that
hands a marked chat its provider child back is the recovery the offer existed to
perform, so it stops being advertised and stops being written back at quit, while
the marker stays valid evidence — a user who reopened a chat can still ask the
agent to carry on. Teardown re-derives the snoozed offer rather than round-tripping
raw markers, and this teardown's own witness now outranks the stale claim for the
same chat instead of being overwritten by it, which was silently persisting an old
turn id and making the next launch refuse the chat that was actually mid-turn.

On the renderer the candidate list gets its own producer against
agentSession.restartResumable, so the status entry and the dialog read one
host-owned answer instead of the dialog pushing its local state at a sibling. The
entry re-reads the host before reopening, so a reopened list can never name a chat
the host would now refuse; dialog open becomes the external one-shot request
rather than a flag mirrored into render state, which is what let a reopen replay
the launch answer and re-offer chats already reconnected. Dismiss all is quiet
rather than destructive, saves the preference like every other exit, and reports a
write the host never confirmed instead of trapping the dialog open.

* fix(native-chat): keep a durable offer a launch never read, and settle the one a continuation spent

Teardown replaced the recovery capsule with whatever this launch still owed,
and a launch that never read the offer owes nothing — so a quit after a
failed first read, a disabled flag, or a window that never mounted deleted a
recovery the user was never shown. The write-back now distinguishes "claimed
and still owed" from "never claimed": the first is re-derived as before, the
second carries forward verbatim, because nothing revealed those sessions and
the predicate would refuse every one for want of a journal nobody opened.

Reconnect and continue spent the same claims Reconnect does but never shrank
the offer, leaving the status bar counting chats the host had already handed
back and sending the user to an entry that re-reads, finds nothing and does
nothing.

* fix(native-chat): stop a teardown answering for an offer it could not read

Two ways the write-back deleted a durable recovery offer nobody had seen.

A take that FAILED left the claim holding an empty list and reporting that
this launch had answered for the offer. The markers were still on disk,
unread and unknowable, and teardown then overwrote them with its own empty
list. It now writes nothing at all unless it has a witness of its own.

`owed()` read "has the capsule been touched" where it meant "did anything
here LOOK at the offer" — and its own write-back read counted. Teardown is
retried when a phase fails, so the second attempt re-derived carried markers
against a session map eviction had already emptied, refused every one, and
wiped what the first attempt had just carried forward. The flag is now set
only by the paths that actually read or act on the offer.

The mock guard for the carry could not fail: it indexed the session it
claimed nothing had revealed, so re-deriving passed and the verbatim carry
was never the reason it went green. It now runs against no indexed session,
which is what an unread offer looks like.

Also drops the `Not now` row from the preference table, where it was paired
with a dismiss method it no longer calls, and asserts the same thing where
the snooze is already covered. Splits the marker predicate's journal reader
out of the resume host, which was at its line ceiling.

* fix(native-chat): clear the corrupt recovery capsule the take refused

A capsule whose contents no longer parse made take() throw before it ever
reached the clear, so the bad file survived every launch. Nothing else
rewrites it now that a teardown owing nothing readable declines to write, and
the freshness filter runs after the parse, so the 24h window could not release
it either: one corrupt file refused recovery forever.

Clear it inside the same transaction that failed to read it, then rethrow, so
the poison dies on the next launch while callers still see why the take failed.
A clear that fails is swallowed rather than allowed to mask the parse error.
Refusing to expose partial candidates is unchanged, and a read that fails for
any other reason still writes nothing.

* feat(native-chat): make resume the one restart action, and make it actually resume

The restart prompt offered two actions: "Reconnect all", which reattached
and sent nothing — exactly what opening the chat already does — and
"Reconnect and continue", which reattached and asked the agent to carry
on. The vacuous one is gone, the "Not now" button and the info popover
with it, and the feature is now called resume throughout.

"Don't ask again (resume automatically)" now runs the action the button
runs: the launch calls agentSession.restartContinue instead of
agentSession.restartResume, so the preference means what it says. Several
comments asserted the opposite as a structural guarantee and are
corrected. agentSession.restartResume stays: no in-app caller is left,
but it is a published wire method a non-desktop or older client can call.

* fix(native-chat): label the resume button with the number of chats selected

The button read "Resume all" whenever every chat happened to be ticked,
which described the selection rather than the action. It always acted on
the selected chats only. Now it always names that count, with a singular
variant so one chat does not read "1 chats".

* refactor(native-chat): drop the reconnect vocabulary the resume action left behind

Resuming became one action — reattach and ask the agent to carry on — so the
notification helpers no longer need to be told which action they are reporting.
Every caller passed `continue`; the `reconnect` branch, its helper and its
catalog keys are gone.

The dialog and the launch path had grown two copies of the same call: same RPC,
same response shape, same announce-and-settle. That now lives once in the store
module that owns the offer, which also takes the dismiss call, leaving the modal
presentational. The two copies had drifted — only the dialog's caught a
malformed payload — and the unified one keeps the defensive reading.

No behaviour change. `agentSession.restartResume` stays: it is a published wire
method even though nothing in the app calls it.

* refactor(native-chat): derive the resume selection instead of intersecting it

The modal's selection was intersected back against the host's candidate list
before every action, as a guard against naming a chat the host never offered.
That guard could never fire: the selection was already derived from that same
list, so the intersection was the identity. The array of chosen ids is now the
derived value and the lookup set falls out of it, which makes the property
structural rather than checked. The helper had no other caller and is gone,
along with its three tests.

Three tests mocked the resume response in the shape the old API returned. Two
never reached that branch at all; the third only passed because the unreadable
shape happened to exercise the malformed-payload path. All three now use the
real shape, and the malformed-payload behaviour — report an unconfirmed
delivery, leave the offer standing — gets a test that says so.

Also: the candidate reader took two trailing optional parameters, so one caller
passed a placeholder `false` to reach the second; they are an options object
now. `isFolderWorkspaceId` had no caller outside its own module and is no
longer exported. `RestartActionOutcome` only ever describes a continuation row,
so it is named for that. `dismissAll` set a busy flag that nothing could
render, since it closes the dialog first. Several comments repeated an argument
already made in the module they point at.

Settings: the automatic-resume description is one sentence again.

No behaviour change.

* fix(native-chat): make restart recovery explicitly durable

* fix(native-chat): preserve dismissal fence across new interruptions
2026-09-21 16:38:05 -07:00
Jinjing 8307dc5d7b Fix ai-vault-panel-search test for updated consent UI (#22093)
The consent dialog no longer provides a 'Not now' button; only 'Enable'
is available. Removed the test's interaction with the obsolete button
and the input clearing/refilling that followed it.

Also includes E2E failure triage report documenting nine product issues
and their associated Linear tracking.
2026-09-21 16:29:44 -07:00
Jinjing 0bbaadafa4 Clear website annotations after successful delivery (#22060)
* Clear website annotations after prompt delivery

Capture annotation snapshot at send time and selectively remove only
the captured objects when delivered. Preserves edits and additions
made during in-flight delivery.

* rm design doc

* Clear only delivered browser page annotations

Annotations are now explicitly passed to the clear handler, allowing it
to remove only delivered annotations by identity. This preserves any
annotations edited or added after delivery began.

* Distinguish delivered vs user-cleared annotations

Add removeDeliveredBrowserPageAnnotations to remove only delivered annotations while preserving concurrent user edits. Simplify clearBrowserPageAnnotations to clear all annotations for a page when user explicitly clears.
2026-09-21 16:17:29 -07:00
Brennan Benson cd59678394 refactor(agent-launch): assemble host startup-plan inputs in one resolver (#22082)
* refactor(agent-launch): assemble host startup-plan inputs in one resolver

buildAgentStartupPlan was already one shared implementation, but every host
re-derived its argument object by hand from the same four settings
(agentCmdOverrides, agentDefaultArgs, agentDefaultEnv, terminalWindowsShell),
and the copies had drifted.

resolveAgentStartupPlanInputs owns that assembly. What genuinely varies per
launch stays a parameter: the host (platform, isRemote), a requested shell, the
per-launch agentArgs override, and the picked session options.

Fixes a live divergence on the agent.launch path: orca-runtime-create-agent-session
passed sessionOptions without sessionOptionsOverrideAgentArgs, so a configured
`--model` in agentDefaultArgs reached argv alongside the picked model and won on
argv order, while the same launch through worktree.create honored the pick.
The plan also reported no applied sessionOptions, so the chat surface could not
name the model the user chose.

Migrates the four host sites; the eleven renderer sites are unmigrated and still
assemble their own inputs.

* fix(agent-launch): preserve picked options in draft launches

* test(agent-launch): assert draft option precedence
2026-09-21 16:15:44 -07:00
Brennan Benson 6dc00702d2 refactor(floating-workspace): launch the default agent through the shared launcher (#21390)
* refactor(floating-workspace): launch the default agent through the shared launcher

The floating workspace titlebar agent button drove tab startup itself: it built
its own `buildAgentStartupPlan`, created the tab, queued the startup command and
rebuilt the tab-bar order by hand. That is a second copy of what
`launchAgentInNewTab` already does for every other "start an agent here" button,
so a launch-point change had two places to land.

The button now calls `launchAgentInNewTab` and keeps only its own placement:
selecting the tab inside the floating panel's unified group and focusing it.

`launchAgentInNewTab` gains an optional `activate` so a caller that places the
tab itself can keep the new terminal out of the global selection. The floating
panel needs this — activating would move the main window's active tab to a tab
it does not show — and it matches the other floating tab creators, which already
pass `activate: false` to `createTab` and select via `activateTab`.

Two behaviours change, both fixes:
- tab-bar order now goes through `persistAgentLaunchTabOrder`, which reconciles
  editor and browser tabs. The hand-rolled loop rebuilt order from terminal tabs
  only, dropping the floating workspace's markdown and browser tabs.
- the startup plan now carries the resolved Windows shell, so argument quoting
  matches the shell the PTY actually gets.

* test(agent-launch): pin the floating button as a launch funnel caller

The census exists so a new launchAgentInNewTab caller is a deliberate act. This
entry is a bypass converging, not a bypass appearing: the button previously
hand-rolled the helper's terminal arm against queueTabStartupCommand.

* fix(agent-launch): preserve floating terminal launch boundaries

* fix(agent-launch): honour the chat-view default in the floating workspace

The floating launch button now routes through the shared launcher, so it should
inherit the same launch policy as every other caller. The previous review pass
added a `workspaceKind === 'floating'` guard to `decideInitialAgentTabViewMode`,
which silently dropped `openAgentTabsInChatByDefault` (and the user's model and
effort preferences) for that one button.

That guard was not justified. `canToggleNativeChat` has no workspace-kind gate,
so a floating terminal can already be switched into the chat view by hand, and
`TerminalPaneNativeChatPortal` mounts into the pane's own container — the panel
already renders it. Refusing the setting at launch while the same view sits one
click away in the same panel is an inconsistency, not an invariant.

Structured sessions stay out of the floating workspace on the pre-existing
blocker in `resolveStructuredNativeChatSupport`: those open an `agent-session`
tab, and the floating panel renders no such surface.

* test(agent-launch): record why floating routes to the terminal-backed chat lane

* refactor(floating-workspace): record why the launch does not take the global selection

* refactor(agent-launch): lift launch execution-context resolution into its own module
2026-09-21 16:07:37 -07:00
Jinwoo Hong 55378fce5b chore(mobile): repin the recording corpus to main's tip after #22072 (#22101)
#22072 re-recorded the speech.* goldens with baseline at its own branch
commit e17b2cf603, which the squash left unreachable from main. Bumped to
main's tip 86b93e02a7 and re-recorded: the diff is the baseline header in
787 goldens and the manifest's baseline line, nothing else, so the
recordings are identical and the skipped commits changed no observed
behaviour.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 19:04:31 -04:00
Neil 35fe67b610 fix(perf): measure terminal latency with presented CI frames (#22096)
* fix(perf): present benchmark frames only on isolated CI display

* fix(perf): wait for the benchmark page before presenting its window

* docs(perf): record full scale pass with unchanged latency budgets

* test(perf): document and verify the isolated display exception
2026-09-21 16:03:40 -07:00
Jinwoo Hong 86b93e02a7 feat(mobile): the microphone owns the wake lock, and the stop reply carries the tail (OTA phase C, ruling 36) (#22072)
* feat(mobile): give the microphone its own screen lock (OTA phase C, ruling 36)

An open microphone holds the screen; a closed one gives it back. The lock
lives in the device-side capture on both hosts — the shell's
`native.audio.start|stop` handler and the native seam — so the page never
decides anything about the screen.

One tag per capture, minted by the module that owns the mic. Both captures
give it back on every close path: a stop, a page session ending with the
capture open, a device that would not begin, and an engine that throws after
the capture is open, which now ends the capture rather than leaving it live.

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

* feat(mobile): carry the capture's tail on the stop reply (OTA phase C, ruling 36)

`native.audio.stop` drains what the ring still holds into its own reply, so
the page's `end()` is one verb: stop, hand the bytes on, done. The drain,
await and read-once-more ordering goes with it, and so do `ending`,
`reading` and `released` — three variables that existed only to order a last
read against the stop and to stop a refused read re-entering `end`.

The tail fields default rather than being required: the page updates over
the air and the shell does not, so a page this new can meet a shell that
answers `stopped` alone. That dictation loses its tail where a required
field would have lost it the stop.

The heap case from PR D's bot round cannot recur: `end` issues no read, and
a stop reply carries no interruption, so the lane that re-entered is gone.

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

* refactor(mobile): drop the dictation finish id, which ordered nothing

`finishingIdRef` tracked the dictation a stop was finishing, and every state
it could name was already named: `cancel`, a disable, an unmount and a newer
start each bump the generation or clear the active id, so the finish guard
answered the same either way. Its one distinguishing arm released pending
audio bytes for a dictation whose budget `closeDictationAudio` had just
reset, and could subtract those bytes from a newer dictation's reserve.

`acceptingChunksRef` stays: it is what stops a late microphone event being
sent after the capture handed over its tail and before the finish goes out.
`pendingChunksRef` stays: `stop` awaits it so the finish cannot overtake the
last chunk send.

The finish guard is pinned by a case that cancels while the finish is in
flight; neutered, it reds.

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

* feat(mobile): delete the page's wake-lock seam (OTA phase C, ruling 36)

The page never names the screen now. `native.wakelock.set` is gone with its
schemas, its shell server, its grant rows and its harness entry; so are the
page's keep-awake owner, the Android foreground re-acquire, and the
`DictationKeepAwakeDevice` the capture contract carried. One module holds
the screen — the device calls the microphone's capture makes — and both
device-side captures share its one tag, because there is one microphone.

Deleted: native-wakelock.ts (120), native-wakelock.test.ts (140),
mobile-dictation-keep-awake.ts (248), mobile-dictation-keep-awake.test.ts
(440), mobile-dictation-foreground-keep-awake.ts (78). With the tag pools
gone, the desktop-start flow has one stale check instead of two, no startup
budget to wait out and nothing to release.

A source-scanning census pins it: no module under mobile/src or mobile/app
but the one owner imports expo-keep-awake, and nothing anywhere names the
retired verb. It reports the file and line, and checks the owner does import
the package so the absence is the rule holding and not the match missing.

KNOWN RED, reported and not recorded over: 25 golden cases in the speech.*
families fail. The recorder adapter had to drop its keep-awake owner, which
moves `adapterSha256` for every golden that mounts it, and the deleted
owner's id minting shifts the deterministic random sequence, so the recorded
`dictationId` values move too. No speech.dictation.* param, reply or
operation changed. Awaiting the lead's call on a scoped re-record.

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

* test(mobile): re-record the corpus after the wake-lock deletion

Baseline bumped to 491eb35b4d and the corpus recorded against it. Every
changed line in `goldens/` falls into six classes and nothing else:

  baseline                            1574 lines   787 goldens
  lockfileSha256                      1574 lines   787 goldens
  adapterSha256                         38 lines    19 goldens
  scenarioSha256                         8 lines     4 goldens
  dictationId shift                    728 lines     4 goldens
  keep-awake effects + renumbering      63 lines     2 goldens
                                      ----
                                      3985 lines, which is the whole diff

`recorderSha256` is untouched: no recorder module outside `adapters/` moved.

The id shift is attributable arithmetic, not a behaviour change. The
recording scheduler seeds `Math.random` with an LCG from seed 1; replaying
it gives draw 1 `8ig2henseon` and draw 2 `dakoxjr8wun`. The deleted
keep-awake owner minted its id from draw 1 during the hook's mount, so the
dictation id took draw 2. With the owner gone the dictation id takes draw 1,
which is why four scenario steps that pinned the literal value move with it.

`adapterSha256` covers `speech.setup-sheet` as well as the three dictation
families, because one adapter module hosts them all. The two goldens with
vanished effects also renumber the ordinals after them, which is what the
removal of an entry from a sequential counter does.

`lockfileSha256` is provenance that `compareGolden` copies from the actual
and never fails on. It moves in all 787 files because main's own
`mobile/pnpm-lock.yaml` has moved since the corpus was last recorded; this
branch does not touch it.

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

* fix(mobile): drop the retired wake-lock grant C7.7's route row carried

The merge brought in the session route's manifest entry, which names all
four dictation grants including `native.wakelock.set`. This branch deleted
that verb, so the row granted a page something the shell no longer serves.
Ruling 32 item 6 said C7.7 takes the dictation grant from PR D's census in
this merge; this is that.

Nothing caught it automatically: the shell-side grant list is derived from
the verb tuple and is already three, and the closure census holds a route to
the grants it needs rather than refusing ones it does not.

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

* test(mobile): repin the session-route closure at the measured 4,331

Measured on this merge rather than summed: 4,331 modules, 989 local.

Main is red here on its own pin. `3cfb070294` measures 4,333 / 991 against a
committed 4,330, three modules this branch never touched — measured in a
throwaway worktree detached at that commit, with the same generators run.
This merge measures 4,331 / 989, and diffing the two local lists gives the
difference exactly: `mobile-dictation-keep-awake.ts` and
`mobile-dictation-foreground-keep-awake.ts` leave, and nothing joins. So the
branch's own effect is the -2 ruling 36 implies, and repinning to the
measurement is also what takes main's closure test green again.

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

* test(mobile): repin the session-route closure at 4,331 on the merge with #22067

Measured on this merge: 4,331 modules, 989 local, against main's freshly
repinned 4,333 / 991 at `3cfb070294`.

Both provenances kept. #22067 names the three `src/shared` modules #21924
pulled into the page closure, which is what made main's earlier 4,330 stale;
this branch's own -2 is the page's wake-tag owner and its Android foreground
re-acquire, deleted by ruling 36. Diffing the two local lists gives exactly
those two leaving and nothing joining, so the number is a reading rather
than 4,333 minus an argument.

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

* fix(mobile): close the capture when the desktop start fails

The hook opens the microphone before it asks the desktop for a session, so a
refused session left the mic open — and, since the screen rides the mic, the
display held until the user cancelled, retried, or the screen unmounted. The
failure arm now runs the same `rollbackRecordingStart` the commit failure
does, because "undo the capture this start opened" is one thing and the hook
owns it; guarded like that arm, so a seam that throws on the way down cannot
take the desktop cancel with it.

Red-first on both hosts. Natively, a new test drives the real seam under the
engine and keep-awake mocks: the refusal used to leave `initialize` with no
`toggleRecording(false)` and a held screen. On the page, the mic control's
own test over the port pair saw `native.audio.start` with no
`native.audio.stop`. Two unit cases pin the call itself, including for a
start nobody will report.

Ruling 36's own words: mic closed means released. This closes the mic rather
than adding a release beside it.

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

* test(mobile): re-record after the failed-start capture close

Baseline bumped to a1bdee9af9 and the corpus recorded against it. The whole
diff is two classes:

  baseline   1574 lines   787 goldens
  content      74 lines     1 golden

`lockfileSha256`, `adapterSha256`, `scenarioSha256` and `recorderSha256` do
not move: no lockfile, adapter, scenario or recorder module changed.

The one content golden is
`matrix-speech.dictation-start-speech.dictation.start-1`. Its failure
partitions now carry a `rollback-recording` effect at ordinal 3, which is
the capture being closed, and the `speech.dictation.cancel#1` entries after
it renumber from 3,4 to 3,4,4,5 — the pool holds one entry per distinct
content, so a partition whose ordinal moved stops sharing an entry with the
one it used to match. Removing the new effect and ignoring ordinals makes
the two recordings identical, checked by dereferencing every hash rather
than by reading the diff.

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

* fix(mobile): only the owning start rolls its capture back

There is one capture seam and it carries no start identity, so round 1's
rollback let a stale start's rejection end a live newer dictation: A opens
the capture and waits on the desktop, the user cancels, B starts and is
recording, A's request finally rejects and ends B's microphone and hands
back B's screen. The rollback now runs only while this start is still the
current one, which is what `wasCurrent` on the line above already reads; a
stale failure still cancels its own desktop session and touches nothing
else. Past the generation the capture was either already ended by whatever
superseded this start, or belongs to the one that did.

Red-first on both hosts, driving that exact sequence rather than a spy: the
native test over the real seam saw the screen go `+ - + -`, and the page's
mic-control test over the port pair saw a fourth `native.audio.` verb after
B was recording. Both now end with B still holding what it took.

The mirror image is covered and now pinned at host level too: A's request
resolving late does not commit A over B, because the stale check after the
desktop start returns through `cancelStaleStart`, which cancels A's session
without touching the capture.

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

* test(mobile): re-record after the stale start stopped rolling back

Baseline bumped to e17b2cf603. Two classes, the whole diff:

  baseline   1574 lines   787 goldens
  content      74 lines     1 golden

`lockfileSha256`, `adapterSha256`, `scenarioSha256` and `recorderSha256` do
not move.

The one content golden is
`matrix-speech.dictation-start-speech.dictation.start-1`, whose scenario is
the superseded start, so every partition in it is a stale one. The
`rollback-recording` effect round 1 put there is gone, and the
`speech.dictation.cancel#1` entries fold back from 4 to 2 as the ordinals
after it renumber — the pool holds one entry per distinct content, so
partitions whose ordinals agree again share an entry again. Dropping that
effect from the superseded partitions and ignoring ordinals makes the two
recordings identical, checked by dereferencing every hash.

`speech-desktop-start-recording-failed` is untouched: that start still owns
its capture at failure time, so it still rolls back.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 18:57:55 -04:00
Brennan Benson da982a4eb0 fix(native-chat): navigate to open history sessions (#21283)
* fix(native-chat): navigate to open history sessions

* test(native-chat): provide structured session predicate
2026-09-21 15:44:19 -07:00
Jinjing 73b726c64f refactor: use generic VirtualizedList in conflict review (#22092)
Replace SourceControlVirtualFileList with a reusable VirtualizedList component,
and update related constants and test IDs to reflect the generic nature of the
component. This extracts the virtualization logic to a shared utility that can be
used across different features.
2026-09-21 15:24:58 -07:00
Jinwoo Hong 6731f08c0b fix(mobile): hold every hybrid shell switch on a neutral state while the flag is unresolved (OTA phase C, ruling 33.7) (#22077)
* feat(mobile): give the hybrid shell switches a third answer for the unresolved flag

Every route switch read the flag as `enabled !== true`, which spends the
window before the read settles on the native screen. With the flag on
that window costs a full native mount — subscriptions opened, screen
painted — that the shell then tears down and replaces.

`shellSwitchDecision` answers `pending` there instead, and
`ShellSwitchPendingScreen` is what a switch paints while it waits: the
base background with nothing on it, lifted out of the `web` route where
this view already was rather than written again.

A route the shell could never open is still answered `native` with no
wait, because the flag cannot change that outcome and a neutral frame in
front of a decided one is the flash this removes.

No switch is wired to it yet.

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

* fix(mobile): hold every hybrid shell switch on the neutral state while the flag is null

All ten switches read the flag as `enabled !== true`, so the window
before the storage read settles was spent on the native renderer. With
the flag on that window costs a full native mount — the session screen
opens its terminal, chat and tab subscriptions — which the shell then
tears down and replaces, and the user sees the native screen flash
before the page.

Each now asks `useShellSwitchDecision` and paints
`ShellSwitchPendingScreen` while the answer is `pending`, so exactly one
renderer mounts and it mounts once. `tasks` and `agent-history` build
their route before the decision rather than after it, because the
decision needs to know whether the shell is a possible outcome at all.
`web` already had this frame inline and now takes the shared one; its
spinner's accessible name moves from "Checking host" to "Loading".

The two cases that pinned the old behaviour — "renders the native panel
while the flag read is still settling" on the files and agent-history
routes — now assert that neither renderer mounts there.
`shell-switch-null-flag.test.tsx` drives all nine switched routes
through the three states and counts committed mounts rather than
renders. Five route tests gain a `react-native` mock, which the neutral
screen's `View` is the first thing in their graphs to need.

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

* test(mobile): fence the neutral state across every hybrid shell switch

Reading the flag through the shared decision is not on its own enough.
A tenth switch could ask `useShellSwitchDecision`, ignore `pending` and
fall through to its native screen, satisfying the reader rule and still
flashing native in front of a flag-on user. So the census also says
every switch names the neutral screen — existence, not shape; where it
names it is the route tests' business.

`matchesOf` is the snippet reader beside the three rules: the needles
are identifiers, and a list of paths says which file moved but nothing
about what in it did. Verified as a fence by deleting the `pending`
branch from the tasks switch, which the rule caught and named.

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

* test(mobile): measure what the neutral window costs a released phone

The only thing this change costs a user with the flag off is the window
itself, so it is worth a number rather than a claim.
`loadMobileWebShellEnabled` answers `false` outside `__DEV__` before it
looks at the key, so a release build reaches AsyncStorage zero times:
the window is React's own passive-effect flush and one microtask, not a
bridge round trip, and the switch has its answer on the first turn after
the first commit. Pinned per switch, because a reader that grew a
storage call would move it from a microtask to a bridge hop.

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

* test(mobile): type the switch table so the tests-typecheck ratchet reads it

`as const` on the table made the catch-all's `page` a readonly tuple,
which `useLocalSearchParams`' own param type does not admit, and the
file dropped out of `tsconfig.test.json`. An explicit element type says
the same thing and checks.

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

* fix(mobile): keep release builds out of the neutral state entirely

Review round 1 (pullfrog). The hook started at `null` on every build, so
a store build committed one neutral frame before its native renderer —
the one cost this change landed on every released phone, and it bought
nothing there, because the flag cannot be turned on outside `__DEV__`.

`mobileWebShellFlagCanBeOn` names that build-kind test once, beside the
reader that already made it, and the hook starts its state on the answer.
Outside `__DEV__` the hook holds `false` from its first render, the
`pending` branch is unreachable, and a store build commits native on
frame one. The effect still runs and still answers `false`; the
initialiser is a starting point, not a second read path.

Red-first, both build kinds pinned rather than inherited from the runner:
18 of 54 cases failed — "commits native on its first frame" and "does the
same when the bundler defined no `__DEV__` at all", nine switches each.
The neutral screen is mocked with a mount counter now, because a frame
committed and replaced inside one `act` leaves nothing in the tree; its
shape stays pinned in the web route's test, which renders the real one.
The census gains the build-kind fence as a third rule.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 18:24:30 -04:00
Neil 6ee7e9511b fix(wsl): preserve OpenCode agent variant in guest
Preserves the selected OpenCode variant across WSLENV so WSL status detection remains correct when native and WSL installations coexist.
2026-09-21 15:15:30 -07:00
Jinjing 7047cdc0d4 refactor: virtualize artifacts list with reusable component (#22061)
Extract SourceControlVirtualFileList to a generic VirtualizedList component
and apply windowing to the artifacts table for efficient rendering of large
lists. Add aria-setsize and aria-posinset announcements to windowed rows
when opted in.
2026-09-21 15:05:31 -07:00
Brennan Benson 93e4407180 fix(build): admit typecheck projects by memory, not core count (#22074)
The typecheck job ran all four tsc projects at once whenever the machine had
more than one core. Two of them are expensive -- tsconfig.node.json peaks at
6.3 GB of tsc heap and tsconfig.tc.web.json at 5.6 GB, measured with
--extendedDiagnostics -- so together they reach ~14.5 GB on a 16 GB runner.
Past that the runner agent is killed mid-check, and the job reports "The runner
has received a shutdown signal" with an orphaned tsc, not a type error.

Attempt-1 typecheck failures were 0 across Sept 11-17 and then 5-26% per day
from Sept 18, with no change to the scheduler in that window. What moved was the
codebase: src/main grew 23% and src/renderer 8.5% between Sept 1 and Sept 21,
which is what pushed the pair over the line.

Projects now carry their measured peak heap and are admitted heaviest-first
while the batch fits both a memory budget and the core count, so the two
expensive projects never share a runner. On a 16 GB / 4-core runner that plans
node+cli+mobile-web (10 GiB) then web (6 GiB), measured at 8.6 GB peak instead
of 14.5 GB. A roomy machine still runs all four together, so local typecheck is
unchanged. A project larger than the whole budget still runs alone rather than
producing an empty batch.

The runner body moves behind the standard direct-invocation guard so the
admission planner can be imported and tested without spawning tsc.
2026-09-21 14:59:31 -07:00
297cfe0cf3 fix(usage): price GPT-6 Astra, and declare when the Codex cost total omits a model (#22073)
* feat(usage): price GPT-6 Astra token usage

gpt-6-astra was missing from MODEL_PRICING, so normalizeModelForPricing
returned null and estimateCostUsd dropped every event on that model from
the total. Stats & Usage showed ~$0 for hundreds of millions of tokens
with no unpriced indicator, since hasInferredPricing only covers a
missing model name, not a missing table entry.

Rates are the published ones: $10 input / $1 cached input / $50 output
per 1M, with the >272K long-context tier at 2x input and cache and 1.5x
output, which the existing tier fields already express.

Fixes #22005

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(usage): say when the Codex cost total omits an unpriced model

A daily row whose model has no `MODEL_PRICING` entry gets a null cost, and
`buildSummary` simply skips it. As long as one other row is priced,
`hasAnyBillableCost` is true, so the Codex card prints a confident dollar
figure that silently leaves those tokens out. That is how GPT-6 Astra usage
read as near-$0 before the entry landed, and it is how the next unpriced
model will read too.

`hasInferredPricing` does not cover this: it only fires when a rollout has no
model name at all, and its label ("inferred pricing") describes a guess, not
an omission.

So the summary now carries `hasUnpricedModels`, set when a row has a model
name and no price, and the estimated-cost card appends
"• excludes unpriced models" — the same bullet-suffix idiom the breakdown
rows already use for "• inferred pricing". The number stays; it stops
claiming to be the whole bill.

* fix(usage): caveat the Overview total too, and only when a remainder exists

Review of #22073 found the Codex caveat stopped at the Codex tab. The
Overview tab prints a combined total across providers and already has a
"- some model prices are unavailable" line, but `hasPartialCost` only
noticed a provider whose whole cost was null. A Codex range with one
unpriced model among priced ones kept a real number, so the line stayed
hidden and the Overview repeated the same confident, incomplete figure.
`UsageProviderOverview` now carries `hasPartialCost` — set from
`hasUnpricedModels` for Codex, false for the providers that cannot yet
report it — and the reduction ORs it in. No new string.

Second, the Codex card could read "n/a • excludes unpriced models" when
nothing at all was priced. "Excludes" promises a remainder, and there was
none. The suffix now also requires a non-null total; that case is still
declared, on the Overview, through the null-cost path.

`unpricedCostLabel` becomes `costCardLabel`, since it holds the plain
label whenever there is nothing to qualify.

---------

Co-authored-by: Alfred212121 <58665898+Alfred212121@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:37:50 -04:00
Jinwoo Hong 9267423f22 refactor(mobile): the bridge host mount reads one args ref, and the handshake fact lives in the session (OTA phase C, audit item 3) (#22080)
* test(mobile): pin every bridge callback to the render on screen

The host is built once per session, so each callback it holds has to reach the
render that is on screen rather than the one that built it. Only the page fault
was pinned that way; this covers the eight a frame can reach, and asserts the
host was not rebuilt for the re-render by leaving a request open across it.

Green before the collapse that follows: the pin is an invariance guard, not a
reproduction. Verified to fail when a callback is closed over instead of read.

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

* refactor(mobile): read the bridge host's props through one args ref

Sixteen latest-callback and route mirrors, sixteen assignments and a sixteen-
entry dependency list become one ref holding the whole props object, written in
one layout effect with one dependency, and one adapter built with the host that
reads it at call time. Adding a callback was a three-place edit the React Doctor
gate has caught four people missing; it is now one entry in the args type and one
line beside the host.

The props type is named for that: the host is built against the whole object.
`viewRef`, `hostRef` and `establishedSessionRef` stay, as do the host's own
`serving`, `initSent` and report-once flags.

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

* test(mobile): pin the handshake fence to the session, not to the mount

Red on this head, in three places. The bridge hook has no way for a caller to
say the session is already open, so a page whose host was rebuilt for a new
client is refused; the session hook does not report the handshake its reducer
already records; and the screen has nothing to hand over.

The second bridge case is the control: the same frame on a session the caller
says nothing about is still refused, so the first one is the fence moving rather
than the fence going.

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

* refactor(mobile): move the bridge handshake fact into the session it describes

`establishedSessionRef` kept "this session has completed a handshake" for the
life of one mount, so the fence a rebuilt host inherits was remembered beside
the session rather than by it. The reducer already records the same fact as
`pageReady`; the session hook now reports it and the screen hands it over, so
the bridge host takes it from the render.

`options.sessionEstablished` stays. It is the host's own seed for `initSent`,
which is the fence a rebuild has to inherit, and the red cases above are what
say so.

The shared bridge harness gained the screen's half of that: it records the
handshake on `onPageReady` and re-renders, which is what the client-rebuild case
was reading off the deleted ref. No existing case body changed.

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

* fix(mobile): drop the dependency list from the bridge args refresh

A caller builds the props object inline, so every render is a new one and there
is nothing for the list to compare; React Doctor reads that as a dependency
recreated each render, and it is right that the list says nothing. No list is
what the ref is for: it runs after each commit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 17:35:05 -04:00
Neil 1a3f4e88c0 fix(opencode): support v2 plugins under plain executable name
Supports OpenCode 2 installed as opencode, including plugin loading and quick-command submission.
2026-09-21 14:29:04 -07:00
Jinwoo Hong 9fea4d1ade fix(rate-limits): keep polling Claude usage for Fable accounts during live sessions (#22071)
* fix(rate-limits): keep polling Claude usage for Fable accounts during live sessions

The statusline feed carries the 5-hour and 7-day windows but never the
Fable weekly window. Each live post rewrote the whole provider snapshot
with a fresh updatedAt, and the automated OAuth poll skipped whenever
that snapshot was under five minutes old. Posts arrive every fifteen
seconds while an agent works, so for accounts with a Fable quota the
meter froze until the session idled.

The skip now applies only when the live feed covers every window the
poll would return, i.e. when the account has no Fable window. Accounts
that have one keep the normal fifteen-minute cadence.

A live post also flipped the snapshot to ok, which dropped the 429
Retry-After and let the next poll land inside the throttle window. The
live snapshot now carries retryAtMs forward and the Retry-After check no
longer depends on the error status.

Fixes STA-8066.

* fix(rate-limits): carry a 429's Retry-After through the live-fresh short-circuit

resolveClaudeFetchApply returns the live snapshot verbatim when a poll
fails while the statusline feed is fresh, so the Retry-After the 429
just reported never reached the poll gate and every cycle re-hit the
throttle. Copy retryAtMs onto the kept snapshot.
2026-09-21 17:25:30 -04:00
Neil 8cf0e81ced fix(perf): calibrate report budgets without masking latency stalls (#22075)
* fix(perf): calibrate report budgets without masking latency stalls

* docs(perf): record historical evidence for report limits
2026-09-21 14:13:09 -07:00
Neil 72a1b148c1 fix(settings): make terminal theme selection override Ghostty colors (#22069)
* fix(settings): clear terminal overrides when selecting a theme

* test(settings): cover light terminal theme override reset
2026-09-21 14:10:02 -07:00
Brennan Benson d60043787b feat(agent-launch): carry the launch inputs the host cannot derive (#22037)
* feat(agent-launch): carry the launch inputs the host cannot derive

Desktop's launch call sites cannot move onto `agent.launch` while the wire
drops inputs they depend on. This adds the three the host genuinely cannot
work out for itself, and deliberately adds nothing the host can.

- `agentArgs` — the host read only `settings.agentDefaultArgs`, so a saved
  launch recipe's arguments had no way across. Tri-state is preserved: `null`
  is "no arguments", absent is "use the settings default".
- `cwd` — `TerminalCreateOptions.cwd` already reached the spawn, but nothing
  on the wire filled it. It also decides the route: only a terminal can start
  somewhere other than its workspace, so the host now feeds it to
  `requiresTuiLaunchCommand` and downgrades with `tui_launch_command` rather
  than running a structured session in the wrong directory.
- `launchSource` — telemetry, and the only member of the `agent_started`
  triple the host cannot derive; `agent_kind` and `request_kind` are computed
  host-side. Typed `z.string()`, not the closed enum: params are validated by
  the HOST, so a closed arm set would let an older host refuse a newer
  client's launch over a label. Attribution must not gate a user action.

Not added, because the host already derives them: `launchPlatform`
(`getAgentLaunchPlatformForWorkspace`, from the same connectionId/path/
projectRuntime the renderer uses) and `startupCommandDelivery` (a pure
function of the agent inside `buildAgentStartupPlan`).

Fingerprint: `agentArgs` and `cwd` are in — they change what the call does, so
a retry carrying different ones must conflict rather than replay.
`launchSource` is out — two buttons producing the same launch are one
operation, and folding it in would refuse an honest re-attributed retry. A
caller sending none of the new fields digests exactly as before, because the
canonicalizer drops undefined keys, so launches admitted by an older build
still replay across the upgrade.

Arguments reaching a structured route are ignored by an existing deliberate
decision (the Agent SDK and app-server version their option sets separately
from the interactive CLI), so the host reports it in `warning` instead of
overriding the user's preference on the strength of a field that is not
evidence about the surface.

* fix(agent-launch): forward create-target launch inputs
2026-09-21 13:58:16 -07:00
Jinwoo Hong d40aac0a58 test(mobile): repin the session page closure at 4,333 after #21924 reached it (#22067)
C7.7 (#21977) measured the session route's page closure at 4,330 on a
merge of f07bf8544c and gated green. Before it merged, #21924 turned
agent-session-wire.ts's type-only import of agent-session-record into a
value import, so src/shared/agent-session-record.ts and the two modules
it reaches, agent-session-conversation-name.ts and
surrogate-safe-text-slice.ts, entered the page bundle. Pristine main at
3cfb070294 reads 4,333. Named by diffing the closure at f07bf8544c
against 2739246058; nothing on the C7.7 side moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 16:17:29 -04:00
Jinwoo Hong 3cfb070294 feat(mobile): register the session page route (OTA phase C, C7.7) (#21977)
* feat(mobile): switch the session route to the shell, still unregistered (OTA phase C, C7.7)

The review switch's shape, for its reasons. The session screen becomes
`MobileSessionRouteScreen` in `src/session` because `useMobileSessionController` is 32 hooks
deep and opens the terminal, chat and tab subscriptions: at the switch's top level it would
open every one of them behind the page as well as in front of it, since hooks cannot be
conditional. As an element passed for `fallback` it is built and not mounted.

Four query params carried rather than re-derived, each omitted when empty: `name` is a label
the screen otherwise derives from the workspace, `created` is the create flow's one-shot flag,
`warning` is the host's own text, and `paneKey` is a notification tap. `paneKey` is the one the
screen writes back — `use-notification-pane-navigation.ts` rewrites it to empty once it has
switched, through `setParams` on the handoff, which inside the page is the document's own
router — so it has to arrive in the page for that to happen at all.

Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists
it; until then the flag is the only thing that changes and it is off.

Three censuses red without their rows, measured on this tree:
- `shell-screen-route-census.test.ts` `walks the route tree and finds them` named
  `session/[worktreeId].tsx` as a ninth switch the list did not have.
- `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no
  others` reds without `SESSION_ROUTE` in `SWITCHED_ROUTES`.
- `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` named the
  new sibling.

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

* test(mobile): root the session parity family at the screen the route mounts (OTA phase C, C7.7)

The extraction parity pin walks from a root function in `app/h/[hostId]/session/[worktreeId].tsx`,
which is now the flag switch: the walk found no `SessionScreen`, and the runtime-string count went
534 -> 542 on the switch's own param names and path literals.

Rooted at `MobileSessionRouteScreen` instead, which is the function that calls the controller. The
switch's business is which of the two screens renders, not what the session screen does, and its
literals have no place in a hash about the extraction.

Every pinned hash is unchanged, which is what says the body moved and nothing else did: 275 hooks,
77 callbacks, 24 effects, 534 runtime strings, 124 host and 61 leaf JSX facts, 172 style
references, all at the same SHA-256 they had before the move.

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

* feat(mobile): give the page the session screen's stored preferences (OTA phase C, C7.7)

Ruling 7: nothing silently no-ops. The allowlist was one exact key and one prefix, so every
preference the session screen reads inside the page fell back to its default and kept working
outside it — a state the user cannot tell from a preference that does not exist.

The keys are derived from the route's own closure, not copied from design §6. Nine join the list:
`orca:terminal-accessory-layout`, `orca:custom-accessory-keys`, `orca:defaultSessionView`,
`orca:mobileStructuredSendOperations:v1`, the three terminal preferences ruling 7 names
(`orca:terminalTextScale`, `orca:terminalAutocompleteEnabled`, `orca:terminalLinkOpenMode`), and
two the design did not: `orca:hostDockWidth`, which `use-mobile-dock-resize.ts` drags on this
screen, and `orca:hostSidebarWidth`, which `app/h/_layout.tsx` reads above every page route and
which the manifest already names as the reason agent-history declares `storage` at all.

Two are per workspace, not per host. Design §6 has `orca:nativeChatTabs:<worktreeId>`; the module
builds `<prefix><enc(hostId)>:<enc(worktreeId)>`, and `orca:terminalLiveInputDisabled:` has the
same shape. So the narrowing goes one level in from C2.9's: `pageStorageKeysForRoute` and
`isPageStorageKeyForRoute` replace the host-scoped pair, and a session page opened on one workspace
can no more rewrite the tabs of the one beside it than it can another host's pins. Both sides read
the workspace off the route pathname, which is the one fact the shell and the page are each handed.

Every new key's writer notes the mirror before it persists, as `savePinnedIds` does: `init` is
built synchronously, so a write that only reached the store would be one `init` behind.

A refusal is a rejection, not a dropped write. The real AsyncStorage rejects when its store
refuses, and the caller that matters already catches: the durable send journal answers
"Message not sent" rather than putting a mutation on the wire with an operation id no store holds,
which after a crash would send the message twice. `PageStorageRefusedError` names the key and which
of the three refusals it was.

Measured on this tree, which is why the journal needed more than an allowlist entry: one journal
entry with no attachment serializes to 342 characters and 48 unsettled sends put the value past
`PAGE_STORAGE_MAX_VALUE_CHARS` (47 is under it), against a schema that admits 4,096. `init`'s own
`BridgeInitStorageSchema` refines on that bound, so handing the journal over whole refuses the
*frame* and the session screen never opens at all. `pageStorageEntriesForInit` drops such a value
and names it; the page reads a default, which is a degradation rather than a page that does not
start.

Red first, measured here:
- 21 cases across three files on the host-scoped helpers being gone.
- `leaves out a value the page would refuse the whole frame over` reds with the filter bypassed.
- The journal case reds without the rejection, with the operation claimed against a store that
  never took it.

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

* feat(mobile): register the session page route (OTA phase C, C7.7)

One entry in `MOBILE_WEB_PAGE_ROUTES` with ten grants, every one read off a call site in this
route's own closure rather than carried from design §1. Measured here:

  navigate                 7 handoff sites
  externalLink             6 openers
  haptics                 24 trigger sites
  native.clipboard.write   6 sites
  native.clipboard.read    3 sites
  native.media.*           2 sites, one seam (`useMediaPicker`)
  screencastBinary         1 site (`MobileBrowserPane.tsx`)
  storage                 10 exact keys and 2 workspace-scoped, the previous commit's

`pageRouteGrants` is derived from this list, so the row is a consequence of the entry and there is
no second table to edit. The design's list was exactly right; the counts are what say so.

The hop census goes 16 -> 23, measured. All seven new rows are `X -> /h/[hostId]/session/
[worktreeId]`, one from each other page route, and none goes the other way: the session's ten
grants are a strict superset of every other route's, so every hop into it is handed to the shell
and every one of its own targets stays in the document. That second half is asserted as grant
coverage rather than as the absence of seven rows — absent is also what an unregistered route
looks like, which is the shape C4 already had to correct once.

Two censuses gained the route and one is new:
- The haptics seam census, whose route-module map moves to
  `mobile-web-app-page-route-modules.mjs` so the new census below shares it rather than keeping a
  second copy that stops growing when the first one does.
- `page-served-back-control-a11y.test.ts`, which named two controls with no `accessibilityRole`:
  `MobileSessionHeader.tsx:64 role=none label=Back to worktrees` and
  `QuickCommandsSheet.tsx:160 role=none label=Back`. Both get the role. Inside the shell there is
  no native chrome behind them, so a bare Pressable is absent from the accessibility tree.
- `mobile-web-app-screencast-lane-grant.test.mjs` derives `screencastBinary` from the closures the
  way the haptics census derives its token. C6 could not write it: the pane is mounted by a route
  rather than registered as one, so there was no route to pin the grant against (C6 ruling 3).

The derivation census gains C6's half measured against this route rather than against a module
closure read on its own, which is the other half of C6 ruling 3. The composed row for the session
route's own families waits on C7.8's table, and on C4.5's split before it.

Numbers, both ends measured on this tree, never summed:
- Session route closure 4,328 -> 4,329 modules, 978 -> 979 local. The +1 is
  `MobileSessionRouteScreen.tsx`; the route file is one input either way, now the `.web.tsx`.
- Chunk count 65 before and 65 after, against the 72 the fence allows at 14 route keys. The fence
  is untouched: a `.web.tsx` sibling is not a new route key, and this route shared its split.
- Bundle 8,020,519 -> 8,022,202 bytes, 108 assets either side.

`mobile-web-app-route-chunk-closure.mjs` looked the route module up by its exact path, and
`resolveExtensions` puts `.web.tsx` first: the first route with a sibling to be asked for reached
"no output". It tries the sibling first now, which is what the build actually chunked.

Without the manifest entry these red on this tree: `pins every hop the handoff must take away from
the page`, `keeps every hop out of the session local`, `declares only routes the bundle has a
module for`, `reaches the built manifest`, `covers every page route and finds a control in each`,
both haptics-seam cases, and `declares the screencast lane on exactly the routes whose closure
asks for it`.

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

* test(mobile): render-check the session route, and quiet the two things it found (OTA phase C, C7.7)

The render check mounts the registered route in a real browser on the built bundle, under the
header the shells send. It asserts the session screen paints rather than the Unmatched route, that
the Back control reaches the accessibility tree as a real `<button>` with its name, that the
route's own chunk arrives on a client-side navigation, that nothing it paints leaves the origin or
logs a policy violation, and that the three reads the screen makes carry the workspace the route
named — the precondition the rest needs, since a screen that mounted and asked for nothing would
paint the same chrome.

It also asserts, strictly, that the page and console errors are `[]`, which is what found both
fixes here. Measured on this tree before them: two console lines and one uncaught rejection on
every mount of the route, none of them visible natively.

- `use-mobile-session-markdown-actions.ts` registered `BackHandler.addEventListener` with no
  platform guard, and the effect re-registers whenever the dirty-draft list changes. React Native
  Web answers "BackHandler is not supported on web and should not be used." and hands back an inert
  subscription, so the guard was never armed on the page anyway. Gated on `Platform.OS`, as the
  right drawer, the bottom drawer and the file preview already are. There is no hardware back in a
  WebView; the shell owns the phone's, and the page's Back control is where the prompt lives.
- `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch.
  The loader returns on a *refused* `worktree.show` and nothing caught a *rejected* one, so a host
  that will not answer produced `Uncaught (in promise)` on every session mount. Caught at the
  effect rather than inside the loader, whose promise the golden recorder awaits; notes that did
  not arrive leave the ones on screen as they were, which is the module's own policy for a refusal.

**The terminal is not painted here and the file says so at both ends.** A terminal on screen needs
the host protocol handshake, a tab snapshot, a terminal inventory and a `terminal.subscribe`
stream — five hand-written fixtures against five Zod schemas inside a transport double, which is
what the harness's docstring refuses to become. Scripting `status.get` alone was measured here:
the protocol gate reads it and the page paints "Update Orca on your computer" instead of the
screen. What the terminal does under the shipped header is
`mobile-web-app-terminal-render.test.mjs`, on the same component and the same build options.

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

* test(mobile): refresh the session parity pins for the two seam edits (OTA phase C, C7.7)

The previous commit's two fixes are inside the parity family, so three pins moved. Both edits are
one token each and neither changes what a phone renders:

- `'web'`, the `Platform.OS` guard the Markdown actions' `BackHandler` registration gained.
- `"button"`, the accessibility role the session header's Back control gained.

Runtime strings 534 -> 536, with the effect hash and the host-JSX hash moving for the same two.
Everything else is unchanged: 275 hooks, 77 callbacks, 24 effects, 61 leaf JSX facts, 172 style
references, all at the SHA-256 they had before. A separate commit because a reported head does not
move by amend, and because the moved hashes are worth reading on their own.

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

* fix(mobile): report the diff-notes rejection instead of catching it (OTA phase C, C7.7)

The `.catch` the previous commit added to `loadDiffComments` moved a golden, which is a finding
rather than something to record over: `matrix-session.diff-notes-worktree.show-1` certifies the
unhandled rejection as an effect of its loaded checkpoint, so the corpus says the app raises it
today and a fix is a re-record and a review event.

Reverted to `void loadDiffComments()`, with the defect written where a reader of that effect will
find it. `family-recordings.test.ts > session.diff-notes: reply partitions at worktree.show#1` is
green again; it was the one failure in an otherwise clean 8,699-test run.

The render check keeps the observation rather than losing it. Its error assertion is now the exact
list `['RenderCheckShellDouble: the render check answers no RPC']` instead of `[]`, so a second
error reds it and so does this one going away — which makes the file the place the fix is noticed
when someone lands it with the re-record.

The defect, for that PR: the loader returns on a *refused* `worktree.show` and nothing catches a
*rejected* one, so a host that will not answer raises an unhandled rejection on every session
mount. It is not a page fault — the shell's `fault` notify comes from the React boundary and
nothing reaches it — so the generation is not dropped and the screen works; the cost is a
document-level error on every mount.

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

* test(mobile): repin the session effect hash after the diff-notes revert (OTA phase C, C7.7)

The effect pin was refreshed while `loadDiffComments` carried a `.catch`; reverting that (the fix
moves a golden, so it is a finding rather than a line) moves the same hash back off it. Repinned on
the uncaught `void` call, which is what the tree holds and what the corpus certifies.

Count unchanged at 24 effects; nothing else in the family moved.

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

* fix(mobile): reject a page storage write for size only, and log the rest (OTA phase C, C7.7 round 1)

Ruling 33.4. `PageStorageRefusedError` was raised for all three refusals, and two of them have no
catcher: a page-closure writer of an unlisted key awaits `setItem` with nothing around it —
`notification-delivery-preferences.ts:39` plainly, `preferences.ts` in several places — so a key
the page was never allowed to keep became an unhandled rejection in the document. That is a worse
failure than the silent drop it replaced, and it is the one the page can least afford, because an
uncaught rejection there is a document-level error on a screen that is otherwise working.

Scope is now one refusal. `too-large` rejects, because the caller that needs it is written for it:
the durable send journal's composer catches it and answers "Message not sent" rather than sending a
mutation whose operation id was never written down (ruling 7). `not-allowed` and `not-delivered`
resolve and are logged as `[page-bridge] storage-write-dropped`, which is the old behaviour plus
the line a device log needs — a preference that did not stick looks identical to one nobody set.

A batch applies every pair it can, logs every drop, and rejects only if one of them was oversize.

Red first, measured here: seven cases in `page-async-storage.test.ts` red on the rejection, among
them a `notificationDeliveryPreferences` write resolving, another host's pins, another workspace's
chat tabs, and a write the shell would not take. The oversize case is unchanged and still asserts
`PageStorageRefusedError` with the key and the character bound in its message, so the narrowing is
visible as the difference between the two.

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

* docs(mobile): make every count in the session route say the same number (OTA phase C, C7.7 round 1)

Ruling 33.5. Three numbers were stated more than once and two of them had drifted when the merge
took the grant list from ten to fourteen.

- Grants. `mobile-web-page-routes.mjs:100` and `mobile-web-page-route-hop-coverage.test.mjs:57`
  both still said ten. Fourteen in both, and the manifest comment now names the audio verbs beside
  the media ones as things only this route asks for.
- Keys. The manifest said `storage` covers "the ten exact keys and two workspace-scoped ones",
  which counts `orca:last-visited-worktree` — a key this route did not add. Nine exact plus the
  two workspace-scoped, which is what C7.7 put in `page-storage-keys.ts`.
- The journal entry. 342 and 343 are both real and answer different questions, which is exactly
  why one number had to win: an entry serializes to 342 characters on its own and costs 343 in the
  array, the difference being the comma that joins it. 343 is the one that drives the threshold,
  so it is the one stated, with the 342 kept beside it as its derivation. Re-measured here rather
  than carried: 47 entries are 16,140 characters and 48 are 16,483, against the 16,384 cap.

Comments only; no behaviour and no assertion moved. The threshold case in
`mobile-structured-send-page-storage-refusal.test.ts` already asserted the boundary both ways and
still passes unchanged, which is what says the arithmetic above is the code's and not the prose's.

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

* test(mobile): red-first for a pane request over a re-sent init (OTA phase C, C7.7 round 1)

Ruling 33.1's four cases plus the compatibility one, all red: `publishRoute`
is not a member of the host, `onRouteUpdate` is not a member of the page's
client, and `ready` carries no `accepts`.

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

* feat(mobile): deliver a pane request to the mounted page over a re-sent init (OTA phase C, C7.7 round 1)

Ruling 33.1. The session switch keyed on the whole route, so a notification
tap for another pane of the session on screen either remounted the shell (a
bridge teardown and a page reload for a tab switch) or, for the pane already
showing, moved nothing at all: the page cleared `paneKey` on its own router
and the native param kept it, so `SET_PARAMS` wrote the value already there.

`paneKey` leaves the key and travels as a route update. The page declares
`accepts: ['route-update']` on `ready`; the shell re-sends `init` for a
same-path param change only to a page that declared it, and treats a second
`init` for the session the page already holds as a route update rather than a
replacement -- in-flight requests, subscriptions, the storage snapshot (the
same object, asserted) and the generation all stay. The screen reports
delivery and the switch clears the native param, so no later `init` replays a
spent tap. `use-notification-pane-navigation.web.ts` reads the request off a
standing listener; the native file is unchanged.

Wire-compatible both ways without a version bump: `accepts` is optional, an
older page is never sent a second `init`, and an older shell never sends one.
Both degrade to today's lost repeat tap. `BRIDGE_PROTOCOL_VERSION` and every
released native RPC are untouched.

Two files were at their line cap, so two modules came out at their own
boundaries rather than a cap bump: `bridge-init-route.ts` (the route half of
`init`, wanted by the switches, the host and the page) and `bridge-host-route.ts`
(one host's held route and what it may publish).

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

* test(mobile): the session switch and its hardware-back gate get their own tests (OTA phase C, C7.7 round 1)

Ruling 33.2. `mobile-web-shell-session-route.test.tsx` mirrors the eight cases
the files switch has -- route built, native fallback while the flag settles,
repeated params, dot-segment refusal, segment encoding, flag off, remount on a
route change, remount on a param change -- plus the two pane cases: a repeat
tap for the same pane reaches the mounted page twice and a different pane
reaches it once, both with one mount in the lifecycle.

The `BackHandler` gate gets a unit test in the shape of its three siblings.
Reaching it meant the hook declaring the fourteen fields it reads instead of
taking all 268 of the session model, so a probe can render it without building
a session; `MobileSessionDiffCommentsModel` satisfies that by construction and
the one caller is unchanged. No pin in the session parity census moves.

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

* test(config): a call-site census for the six grants that had none (OTA phase C, C7.7 round 1)

Ruling 33.3. `navigate`, `storage`, `externalLink`, the two clipboard verbs
and the media three were pinned only by the list they were copied from, so
striking any of them out of a manifest entry reddened nothing. Each is now
derived from the route's own closure by parsing the call sites -- a call, not
a mention in a comment or a string, and not an import the module never calls
-- and each row has a named control case driven over the session entry with
that row's grants struck out.

It found one thing. `app/h/_layout.tsx` wraps every `/h` route in
`HostProtocolGate`, whose wall offers an Update Orca link through
`openExternalLink`, and two routes reach that without declaring
`externalLink`: on them the link posts a notify the shell refuses. Recorded
exactly as `KNOWN_UNDECLARED` rather than exempted, because widening two other
routes' grants is a capability decision and this is pre-existing on main.

`notificationPaneTab` moves to its own module. A `.web.ts` sibling cannot
import its native neighbour by the plain path: the bundler's
`resolveExtensions` answers with the `.web.ts` file, so that import was the
file itself and esbuild refused the page bundle with a cycle. The mobile suite
does not bundle, so only the closure walk saw it.

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

* test(config): make a struck-out grant red a case named after it (OTA phase C, C7.7 round 1)

The first shape checked the whole manifest at once, so removing any one of
the eight reddened all seven cases and named none of them: the per-row control
read `session.grants` off the manifest the removal had just changed. Each row
now has its own manifest case, and each control is built from what the
session route's closure reaches rather than from what its entry declares, so
it stays green whatever the manifest says. Closures are memoised, which is
what pays for walking all eight once per row.

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

* fix(mobile): hold the pane request in a ref, not in state (OTA phase C, C7.7 round 1)

The changed-code quality gate's React Doctor found it:
`no-adjust-state-on-prop-change`. A tap can arrive before the terminals have
loaded, so the request has to wait; holding it in state meant the effect that
consumed it set state on a prop change, and the stale selection renders first.
The request waits in a ref now and a counter wakes the effect, so the effect
reads and clears rather than adjusting anything.

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

* test(config): re-measure the session closure and list the new web sibling (OTA phase C, C7.7 round 1)

The full `config/scripts` suite found both. The closure reads 4,326 modules
and 984 local, two more than the merge, and the two are named rather than
counted: `notification-pane-tab.ts` and `bridge-init-route.ts`. The pane
hook's web sibling replaces the native file rather than joining it, so it
costs nothing -- but it is a `.web.ts`, so it needs its row in
`web-overrides.json` saying why the native one cannot run on the page.

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

* fix(mobile): keep the page off a journal init could not carry (OTA phase C, C7.7 round 1)

Ruling 33.6, from pullfrog on `beda1cc384`. Dropping an over-cap value from
`init` did not revoke the page's write access to it: the key stays in
`pageStorageKeysForRoute`, so the page read no journal, `parseJournal(null)`
gave it an empty one, and its first send wrote a one-entry value over the
device's -- every native entry lost and a fresh `operationId` for an operation
the native journal already held, which is the duplicate send ruling 7 exists
to prevent.

`pageStorageEntriesForInit` now reports `oversize` beside `dropped`: only the
value-cap drops, because an entry-cap drop is a key that fits and the page's
own write of it is the size the shell would have carried anyway. The shell
sends those names as `init.storageOversize`, and a page write to one of them
rejects with `PageStorageRefusedError` under the size contract of 33.4, which
the composer already shows as "Message not sent". The native journal is
untouched until the user is back on native or it drains.

`storageOversize` is optional in both directions: an older shell sends none
and an older page ignores it, which is exactly today's behaviour. No version
bump; omitted rather than sent empty, so no golden moves.

Red first, with the two states replaced by ones the shell produces. The 47/48
case drives `pageStorageEntriesForInit` rather than publishing a journal value
the shell strips before `publishPageStorage` ever sees it, and the case that
used to assert a successful write now asserts the native entries survive: it
was the clobber, recorded as success.

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

* fix(mobile): deliver a route update only when a param moved, and only after init went out (OTA phase C, C7.7 round 2)

Round 2, findings 1 and 2, both red first.

`onRouteUpdate` fired on every re-sent `init` for the session the page holds,
not only on one whose route moved. The shell answers every `ready` with the
route it holds and the page re-asks on its own backoff and again after a
refused `state` frame, so one tap reached the pane hook as `['', 'pane-1']`.
Both ends now read one definition of moved, `bridgeRouteMoved`, which is the
page's own `shellScreenRouteKey`: the host will not send an `init` for a route
that did not move and the page will not publish one it was sent anyway. The
`.web.ts` hook keeps its empty-pane guard and its comment now says why it is
load-bearing rather than defensive -- the shell's own clear arrives as a move.

`onRouteDelivered` ran on the `ready` path without checking that an `init` had
gone out. A refused route answers the ask with nothing, so the caller would
clear a one-shot param the page never received. `sendInit` reports whether a
frame left and `onPageReady` carries it. Unreachable from the session switch,
which parses the route before it mounts the shell; the prop's contract says it
anyway, and the publish path already honoured it.

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

* docs(mobile): attach the init-storage doc to its type, keep the overrides escape (OTA phase C, C7.7 round 2)

Round 2, findings 4 and 5, neither a behaviour change.

The block describing `pageStorageEntriesForInit` had `PageStorageForInit` and
its own one-line doc between it and the function, so it documented neither.
The type moves above it and the block sits on the function it describes.

`web-overrides.json` had an escaped em dash re-encoded as a literal one when
this branch added its rows through a JSON round trip, on a line about the
keyboard stub that has nothing to do with C7.7. Main's `—` is restored;
`oxfmt --check` accepts the file either way, so this is main's spelling kept
rather than a formatter's demand.

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

* fix(mobile): catch the custom-key save the page store refuses (OTA phase C, C7.7 round 2)

Round 2 addendum. `addKey` awaited `saveCustomKeys` with no catch and both of
its callers are `void addKey(...)`, so the rejection had nowhere to go.
`orca:custom-accessory-keys` is in the session route's page allowlist and a
page write over `PAGE_STORAGE_MAX_VALUE_CHARS` rejects rather than drops (the
size contract of 33.4, extended by 33.6 to a key `init` could not carry), so
past ~16 KB of accessory keys this surfaced as an unhandled rejection in the
page -- which the fault boundary reports and which drops the generation.
Every other allowlisted writer in this closure already catches: the two write
chains in `TerminalShortcutSettings`, the live-input save and the session-view
preference.

Caught at the boundary and logged, and the drawer neither announces the key
nor closes: a row on the accessory bar that no store holds, gone at the next
load, is the failure the allowlist exists to avoid. Red first -- the case saw
the refusal escape with the page's own message -- and the control reds again
when the catch rethrows.

Belongs in `8b4c559e90` by the brief; it is its own commit because that one
was already made and amending is forbidden.

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

* fix(mobile): reject a batch of oversize writes once, not once per pair (OTA phase C, C7.7 round 2)

CodeRabbit and pullfrog, same site. `settleBatch` called `settle` per refusal
and kept the first rejected promise, so a `multiSet` or `multiRemove` with two
over-cap pairs built a second rejected promise nobody held -- an unhandled
rejection in the page, the outcome ruling 33.4's rejection scope exists to
avoid. Two oversize keys is all it takes, and `storageOversize` made a second
way to reach it.

A refusal is now an error or nothing, and only the caller's one rejection ever
becomes a promise. Red first under an `unhandledRejection` listener with two
over-cap pairs: one orphan before, none after, and the caller still hears
about the first key.

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

* fix(mobile): record a route key only once a frame carried it (OTA phase C, C7.7 round 2)

CodeRabbit on `MobileWebShellScreen.tsx:271`. The effect recorded the route's
key and then published, so a publish the hook refused for having no host was
remembered as though it had gone out. `publishRoute` is now keyed on
everything the host is built from rather than on the session alone, so the
render that brings the host re-runs the effect, and the key is written only
after a frame has left.

Reported honestly: this does not repair a lost tap, and the case beside it
says so. The host is built from the route the render holds, so a route that
moved before it existed rides the first `init` either way and `publishRoute`
then answers "did not move". What the change removes is a key recorded for a
frame nobody sent -- the same contract finding 2 fixed on the `ready` path.
The case pins the delivery count across the gap: nothing reported while there
is no host, nothing reported once there is one and it has sent nothing, and
exactly one report when the `init` answering the page's ask carries the route.

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

* fix(mobile): refuse an oversize-dropped key at the shell, not only at the page (OTA phase C, C7.7 round 2)

pullfrog's rollout gap on ruling 33.6. `storageOversize` is honoured by a page
built with it, and the page is served from the desktop: a document from an
older bundle ignores the field and writes the key whole, which for the send
journal replaces every entry the device holds. The shell is the half that
updates with the app, so the shell is where the refusal has to live.

The host now refuses a `storage` notify for a key it could not hand the page,
answering it as the drop it already answers an unlisted key with. The page's
own rejection stays as the fast path -- it reaches the composer as
"Message not sent" with no round trip -- and the schema comment says the field
is advisory and the shell enforces it.

Red first: a host holding the journal as oversize received a page write for it
and posted it to native storage; now it posts nothing and the entries survive,
while a key it did hand over is still writable.

The three refusals became one predicate in `page-storage-keys.ts`, where the
keys are, because inlining the third put `bridge-host.ts` over its line cap.

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

* test(mobile): the page's pane hook gets its own test (OTA phase C, C7.7 round 2)

pullfrog: `use-notification-pane-navigation.web.ts` had no cover. The native
file's test mounts the native file, and `bridge-route-update.test.ts` stops at
the client, so the half that turns a route update into a tab switch was
untested.

Seven cases: the seed from the route the page was opened on, a request held
until the terminals load, a repeat tap on the pane already showing, a
different pane, the clear the shell posts after each delivery, a pane that has
since closed, and a page opened on no pane at all.

Two controls, so the cases are not all satisfied by one behaviour. Dropping
the seed reds the two that read the first `init`. Deduplicating by value
instead of counting deliveries reds the repeat tap, which is the case the
counter exists for.

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

* fix(mobile): roll the custom-keys mirror back when the store refuses (OTA phase C, C7.7 round 2)

CodeRabbit. `saveCustomKeys` notes the write in the mirror before it persists,
because a reader is answered from the map rather than from the store and the
shell builds `init` synchronously from that map. On a refused write the note
stood: the page's next `init` carried the value native had rejected, and every
native reader of the key saw it too.

The previous mirrored value is captured and put back on the failure path, and
the error still goes to the caller so `addKey` keeps withholding the key.

Red first: with the store refusing, the mirror held the rejected value where
the pre-save value belonged.

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

* fix(mobile): report a route delivered only after its frame was posted (OTA phase C, C7.7 round 2)

CodeRabbit on the send path. `sendInit` answered "sent" the moment it handed
the JSON to `post`, and a rejected post was reported a turn later as a
diagnostic -- so the screen spent the one-shot `paneKey` on a frame the page
never received, cleared the native param, and the tap was gone. `publishRoute`
was fire-and-forget the same way.

Delivery is a promise now, settled after `options.post` resolves and false on
either throw or reject. Readiness stays separate: `onPageReady` fires on the
ask, as the shell's wait needs, and carries the delivery promise beside it.
The screen records the route key and calls `onRouteDelivered` only when that
promise answers true, and a refusal leaves nothing recorded so the next render
that can carry the route tries again.

Red first: with the view refusing what it was handed, the frame was built and
posted and the screen reported delivery anyway. Now it reports none while the
page's ask is still reported, and a host-level case pins the same split.

`bridge-host.ts` was at 299 of 300 lines, so the send half came out as
`bridge-host-frames.ts` rather than growing it; the file now measures 280.
The screen's own test harness never attached the view handle, so every post in
it rejected unobserved -- it attaches one now, which is what let the case see
the frame at all.

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

* docs(mobile): fold two doc blocks back onto what they describe (OTA phase C, C7.7 round 2)

pullfrog's two nits, no behaviour change. `page-async-storage.ts` kept the old
`settle` block above `refusalError` when the function it described moved down
with a one-liner of its own; the orphan goes. `bridge-host.ts` had two stacked
blocks on `sendInit` after it grew a return value; they are one, and it now
says the frame is still built synchronously and only the post is awaited --
which is the property the golden recorder depends on.

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

* fix(mobile): let the host own a pending route until its frame lands

A route the page has not received is now the host's, not the screen's. `publish` keeps it
pending until a post resolves true, marks it delivered only then, and reports that through a
callback registered once per host. Movement is measured against what a frame actually reached
the page with rather than against what the host holds, so a refused frame leaves the route owed
instead of reading as one that did not move.

Three things the old shape lost, each a case here: a frame the view refused was never retried,
because only another render could try and a mounted page has none coming; a render while a post
was in flight cancelled the report the switch spends to clear the param; and a repeat tap for
the same pane was held, because the host had already moved its held route on the attempt that
failed. The retries are the moments delivery becomes possible again — the next `ready`, and a
view handle the host regains — and one frame goes out at a time.

Also folds round 4's doc nits: the stale delivery comment the screen no longer has a ref for,
and a leftover `an` in the `ready` branch.

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

* fix(mobile): roll the journal mirror back when persistence fails

`writeEntries` noted the mirror before the store took it, which is what keeps an `init` built in
the same turn current — but it kept the note when the store refused. The page then received a
journal the device never wrote and resumed operations nothing was holding.

Restored on the error path, the same shape as the custom-keys save, and on both halves: the
removal that empties the journal had the same gap as the write that fills it.

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

* fix(mobile): keep publishing until the held route is the delivered one

Two halves of the same gap, both found by the bots on `fba8cb3f3d`.

A route that moved while a frame was out was held for the turn and then had nothing to wake it:
the post settling only cleared the in-flight flag, and on a mounted page no `ready`, handle or
tap need ever come along. A landing is now itself a moment to publish again, while what the host
holds is not what the page has. Only on a landing — a refused post that re-attempted itself
would spin, and that one still waits for whatever makes delivery possible again.

And the report carries the route a frame reached the page with, which the session switch was
ignoring: the older pane landing wiped the `paneKey` naming the newer one, so the page stayed
where it was and the second tap was gone. The switch now spends the param only for the pane that
was delivered.

The delivery cases render through one helper rather than six copies of the same setup, which is
what keeps the file under its cap.

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

* docs(mobile): drop the second block describing a parameter onPageReady no longer takes

The field is documented by the block above it; this one still described the `delivered` promise
the handler was handed before the host took ownership of the pending route.

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

* feat(mobile): let the page erase the route param it was handed

Ruling 34, step one: one page-to-shell frame that asks the shell to clear a one-shot route param,
naming the value the page applied.

Closed at both ends. The param is an enum of what the shell hands over, so a page cannot edit a
route it was never given; the notify name is a member of the closed union, so it gets a row in
the grant table by compilation rather than by memory, and rides no grant because it can only
spend something this shell put there. The shell declares it in `init`, the mirror of
`ready.accepts`: no shipped shell serves a page, so nothing needs negotiating today and the
page's check exists from the first version that can post one.

The comparison belongs to whoever holds the param, which is the session switch: a tap that moved
on while the page was applying the one before it leaves a newer key, and a clear naming the older
one is not for it.

`bridge-envelope.ts` went over its cap, so the page-to-shell union moved to
`bridge-notify-envelope.ts` and the fields both halves spell to `bridge-frame-fields.ts`, which
the envelope re-exports. No cap was raised.

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

* refactor(mobile): re-send init on a route change and track nothing else

Ruling 34, step two: the tracked handoff is gone. Deleted, not patched — the pending route, the
delivered route, the in-flight flag, the landing callback, `retryPendingRoute`, the delivery
promise `onPageReady` used to carry, and the `onRouteDelivered` that ran from the host through
the hook and the screen to the switch.

What is left is the rule in one line: `publish` sends one `init` when the route moved and the
page said it takes one, and every `ready` is answered with the route the shell holds then. A
frame the view refused is repaired by the next ask, not by a retry; the request it carried is
spent by the page.

The cases that tested the deleted mechanism go with it. The outcomes they protected are pinned
where they now live: one frame per move and none for a render that moved nothing, a lost frame
repaired by the next ask, no second `init` to a page that never said it takes one, and the
repeat tap measured through the page's erase rather than through a delivery report.

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

* feat(mobile): the page applies a pane and erases the request that carried it

Ruling 34, step three. The page hook applies the pane an `init` names and asks the shell to erase
the param it came on, naming what it applied.

Two rules, and both are the page's because the shell has none. The erase is asked for on every
`init` that carries a pane rather than only on the one that changed something: a clear that never
reached the shell leaves the param in place, and the next frame carrying it is the repair. The
switch happens once per value: a re-asked `ready` is answered with the route the shell still
holds, and applying that again would drag the page off a tab the user has since moved to.

A repeat tap for the same pane still arrives as a request, because the erase went through in
between and the tap wrote the param back. The client refuses to post the frame to a shell that
did not declare it takes one, which is every shell older than the field.

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

* fix(mobile): keep the page bundle's readers pointed at the module that declares each name

The envelope split left three source-text readers and one import pointed at a file that now
re-exports what they read.

`shell-screen-route.ts` read the route schema back through the envelope, which reaches that file
again through the page-to-shell union: a cycle esbuild resolves to `undefined`, so every page
route mounted onto a schema that was not there yet and the browser render suite failed on twelve
files with a TypeError rather than on a build error. It reads the declaring module now.

The render harness read `BRIDGE_PROTOCOL_VERSION` and `BRIDGE_FAULT_GRANT` out of the envelope by
regex; both moved, and a regex over a re-export answers for whichever file the last split left
them in. Both point at `bridge-frame-fields.ts`, and the throw names it.

The session route's page closure is re-measured on this tree at 4,330 / 988 and the four new
modules are named, not inferred: the two halves of the split envelope, and the route-update
module and route-key reader the page-to-shell union now reaches through it.

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

* test(mobile): count posted inits with the page's own reader, not a cast

The changed-code quality gate refuses a type assertion, and it is right to here: a frame the
page's reader would refuse is not an `init` the page ever saw, so a case counting them must not
count one either.

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

* refactor(mobile): put every mirrored write on one path that notes what the store took

Ruling 35. Fourteen call sites in six files noted the shell's mirror before persisting, and on
the page a persist can be refused: twelve left the map holding a value no store had taken, and
the next `init` handed the page exactly that. Two undid it by hand.

`persistMirrored` is the one path now, and it seats the map from what the store holds after the
write rather than from what it was handed. That is what makes the note follow acceptance without
a second opinion about it: the page's adapter resolves a `not-allowed` write and logs it, so a
rejection is not the only refusal there is, and reading back is the only answer that covers both.
The cost is one store read per mirrored write on the device, where the store refuses nothing.

`writeMirroredStorage` keeps its note-then-persist order and loses every caller but one: the
shell taking a value the page has already applied, into the device store, which has no allowlist
and no frame cap to refuse against. It builds the next `init` synchronously in the same turn, so
noting on the store's reply there would hand the page back the value it just changed. The
last-visited key moved off it, because that module is in the page's own closure.

Both rollbacks are gone with the notes that needed them, and `noteMirroredWrite` is private. A
source-scanning census holds each writer to the path by name.

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

* refactor(mobile): answer a page batch write at the first pair it cannot take

Ruling 35's other half. `settleBatch` collected a refusal per pair, logged each, and rejected
with the first that could reject while the rest of the batch went in anyway — one promise
describing a call where some pairs landed and some did not, which is not something a caller can
act on.

A batch is one call with one answer now: every pair before the refusal is applied, the refusal is
the answer, and nothing after it is attempted. No page-closure writer calls `multiSet` or
`multiRemove` today, so this is the rule for whoever writes the first one rather than a change to
anyone's behaviour; both directions are pinned, including the refused first pair that stops the
rest.

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

* style(mobile): format the mirrored write path's census and journal writer

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

* test(mobile): count the note-first callers the mirror module says are held to one

Two gaps pullfrog found in the census. The last-visited key moved onto `persistMirrored` with no
row naming it, so removing its write path reddened nothing; and `mirrored-storage-keys.ts` says
the census holds `writeMirroredStorage` to one caller while nothing counted them.

Counted now, over every module under `mobile/src` rather than over a list of files a new caller
could sit outside of: a second one is either a writer that wants note-then-persist without the
store that earns it, or a page-reachable module that would note a refusal as an accepted write.

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

* docs(mobile): fold sendInit's doc onto the function it now describes

It still described an awaited post that answered whether the page received the frame, which
ruling 34 deleted: it fires the frame and answers nothing, a refused route sends nothing at all,
and a post the view would not take is one diagnostic and no further attempt.

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

* fix(mobile): let the page own a frame it received and could not handle

Ruling 34's addendum. On iOS the host's post is `callAsyncJavaScript`, which rejects when the
page's synchronous `onmessage` throws — with the document still mounted. The shell reads that as
a frame that never arrived, and it tracks nothing about posts, so nothing would ever send it
again. It is not a lost frame either: the page had it, one of its own listeners failed, and a
retry would fail the same way.

`receive` catches it and reports `inbound-listener-threw`, so the delivery is the channel's and
the handling is the page's. Nothing is swallowed and nothing is retried.

Two cases pinned the throw escaping and now pin it being reported: the ack that a listener bug
must not wedge, and the bootstrap stamp a tree that throws still leaves behind.

With that path closed, a post is refused only when no document holds the view, and the comments
on both halves of the route seam say so instead of naming a backoff that is stopped by then. The
repair is pinned rather than described: a tap that arrives while the view is gone is carried to
the next document's `ready`, because the held route advances on `hold` as well as on `send`.

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

* refactor(mobile): move the page's held session out of the client, which was at its cap

The listener catch put `bridge-rpc-client.ts` at 305 counted lines against a cap of 300, so this
splits rather than bumps.

The session is the one piece of the client with a lifecycle rather than a value: a second `init`
for the same session updates it in place, a different one replaces it and takes the requests and
streams of the session before it, and each case has its own listeners to fire in its own order.
The client keeps the frames and the ports; `bridge-client-shell-session.ts` keeps what they are
for, and the client's three members delegate to it.

The client measures 277 counted lines after the move. The session route's page closure is
unchanged at 4,330 / 988: the page reaches its client from the entry, not from the route module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 16:07:14 -04:00
Brennan Benson 2739246058 feat(native-chat): light the unread indicators when a structured chat finishes (#21924)
* feat(native-chat): light the unread indicators when a structured chat finishes

A structured native chat had no attention producer. The PTY lane reaches the
unread markers through use-notification-dispatch, whose liveness reads PTY
state and whose admission requires terminal panes, so a structured session —
which runs on the execution host with no renderer PTY — could finish a turn
with nothing lighting anywhere. A backgrounded chat was the worst case: with
no mounted pane there was no reader to notice at all.

The host derives the completion, because only the host can. The journal keeps
committing whether or not a renderer holds a reader, so the new feed observes
each commit at StructuredAgentSessionClientDelivery.publishJournal and emits on
every running -> settled transition. That edge runs after the subscriber loop
and independent of it, which is exactly why a chat nobody is watching can still
complete. It is a separate capability-gated stream rather than a field on the
status summary: the summary carries no turn identity and no outcome, and is
re-broadcast on every status change, so folding a completion into it would make
every status consumer a completion consumer.

ONLY `success` LIGHTS ANYTHING. Outcome is A0's provider verdict and is never
inferred: a turn the host merely watched stop carries no outcome and produces
no event, because absent means UNKNOWN. `completed` alone proves nothing — a
provider reports its own API error as a finished turn — so the host emits
nothing for it and the renderer filters again on the way in.

RECOVERY IS LIVE-ONLY. Nothing is retained, queued or replayed on either side.
A subscriber learns what settles while it is subscribed and nothing else; on
reconnect it re-opens an empty stream and whatever landed during the gap is
gone. A retained completion would be a durable "unread is owed" obligation with
nothing to retire it, and a reconnect would then light the dot for work the
user already read. Tests on both sides pin this so a later refactor cannot
quietly turn it into catch-up.

The dot itself reuses the neutral policy in attention/agent-attention-policy
and #21274's structured surface adapter, so suppression, acknowledgement and
addressing keep exactly one implementation and the surface key is never omitted
to evade a check. No second suppression rule is introduced. OS delivery is
deliberately not wired: this calls applyAgentAttentionUnread, not
applyAgentAttention.

Also narrows the completion feed's journal dependency to the newest-turn reader
it actually uses, and adds journal.newestTurn() beside the existing
activeTurnId() on the one shared by-sequence scan rather than a second scan.

* test(cross-version): register the turn-completion subscribe on the wire manifest

The cross-version gate asserts the structured surface's method list by name and
count, so an additive method has to be declared there deliberately. Adding the
entry makes the suite call it in both skew directions and stubs the host side,
which is the statement the gate exists to force.

* fix(native-chat): rebaseline completion feed after rewinds
2026-09-21 13:05:24 -07:00
Brennan Benson dc8cf30554 fix(native-chat): end a structured turn when the agent reports it failed (#22047)
* fix(native-chat): end a structured turn when the provider reports it failed (#22044)

A turn reads as working while its durable turn row says `running`, and only two
events could write a terminal row: the provider's turn-completed notification and
the provider process going away. A provider error that ends a turn is neither, so
the row stayed `running` with nothing re-deriving it, and the chat counted
"Working for N" for the life of the session.

Codex reports such a failure as an `error` notification naming the turn it ended,
with `willRetry` distinguishing it from a stream error it is about to retry. That
frame now settles the turn it names. Claude's CLI reports the same through its
session-state frame, whose `idle` arm the SDK documents as the authoritative
turn-over signal; that now settles the open turn too.

Codex's `thread/status/changed` deliberately settles no open turn: the app server
clears `running` on every error, including ones it reports as not affecting turn
status, so a turn still open there is still running. What it does settle is a send
whose dispatch was never answered — a timed-out dispatch is recorded as unverified
delivery, reads as work still owed, and nothing in a live session retired it.
Retiring it never makes the send re-deliverable.

Splits the codex notification translator so the file stays inside its line budget.

* fix(codex): defer idle dispatch release until turn end

* fix(claude): enable session state lifecycle events
2026-09-21 12:44:13 -07:00
Jinjing f7955e81ff feat(conflict-review): virtualize large conflict file trees (#21920)
Implement windowing for the conflict review file tree using
SourceControlVirtualFileList to efficiently handle large merge conflicts.
Add comprehensive tests for virtualization behavior including scrolling,
collapsing, and dynamic updates.
2026-09-21 12:22:28 -07:00
Brennan Benson 91e6e1f355 fix(native-chat): collapse a finished turn to its answer (#22029)
* fix(native-chat): collapse a finished turn to its answer

A finished turn's "Worked for N" row hid the turn's tool runs and nothing
else. Every sentence the agent said on the way to its answer stayed in the
transcript, so the resting state of a long chat was the narration, not the
reply — one 16m 56s review turn left 21 assistant messages and roughly
seven screens of scrolling behind a control that reads as if it had put
the work away.

The fold's unit is now the turn. A settled turn draws its prompt, its
duration, and its answer; the narration and activity that produced it sit
behind the caret. The answer is the turn's last assistant row that renders
prose — derived, because the journal carries no marker saying which message
is the reply.

Collapsed stays derived rather than stored: nothing closes the disclosure
when a turn ends, it arrives closed because the turn gained a duration. A
running turn therefore folds nothing and the reader watches the work as it
happens, which is what already happened and is now stated rather than
inherited.

Rows that outlive the turn that started them stay outside the fold — a
spawn roster and a background task are often the only record of how that
work ended. So does the reader's own message, question receipts, and the
turn's diff rollup. A turn that produced no prose folds whole, its status
row standing as the anchor.

Two presentation changes come with it, both about the opened view:

- A settled run's header was a call count followed by a monospace list of
  tool names and arguments. It is now one sentence in the transcript's own
  type — "Read 7 files, ran 17 commands, and searched 4 times" — built on
  the tool-category vocabulary that already picks the row's glyph, so the
  words and the icon cannot claim different things. A run of one command
  keeps that command as its header.
- A tool call now owns its result instead of standing beside a separate
  `Result` row, so an opened run lists the work rather than twice as many
  rows half of which say `Result`. Output is one more click. Pairing is
  positional — a result answers the most recent unanswered call — because
  result blocks carry no call identifier to match on.

Command previews also lose the `/bin/zsh -lc "…"` wrapper they all opened
with. The unwrap happens inside `summarizeToolInput`, before truncation,
because the clip at 80 characters removes the closing quote that proves the
wrapper; one site fixes the header, the rows, and the running label.

Measured on a real session journal at 1200x900: the turn above goes from
6,300px across 51 rows to 452px across 2, the whole session from 8,151px
to 2,138px, and the same turn opened from 18,540px to 11,131px.

The fold derivation lives in `src/shared` so the mobile transcript can read
the same rule; wiring mobile's list to it is not part of this change.

* fix(native-chat): preserve FIFO tool result pairing

* fix(native-chat): keep tools collapsed when opening turn

* test(native-chat): clarify independent tool disclosures
2026-09-21 12:13:30 -07:00
Brennan Benson c49b8cd534 Revert "fix(native-chat): stop a subagent's output speaking for the agent tha…" (#22058)
This reverts commit 33ba1ff3df.
2026-09-21 12:11:32 -07:00