Commit Graph
11070 Commits
Author SHA1 Message Date
Jinwoo Hong 740887fbbb feat(settings): connected computers rows for session history indexing (#20887)
* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

* feat(settings): live index status, enable confirm, advanced delete

* feat(session-search): search every computer from the history panel

The panel's "All computers" scope produced no request: the hook parsed the
scope into a single host id and stopped when that was null, so the panel
answered "Choose one computer to search its sessions." The desktop already
merges every enumerated host behind `aiVault:searchSessions`, so pass the
scope straight through and stamp each hit with the host it came back on.

Hosts the merge could not search are named under the results header with a
short reason, since a silent partial answer reads as "no such session".

(cherry picked from commit c6b9179316)

* feat(session-search): enable indexing on paired servers from a client

Adds `aiVault.setSearchEnabled` so a desktop can turn a paired Orca server's
transcript index on or off and have the server apply it without a restart.

The runtime method refuses any caller without a `pairedDeviceId` with a
`forbidden`-class error, writes the whole resolved policy through the runtime
store so retention rides along untouched, then reaches the index through a
host-supplied hook: `applySessionSearchSettingsChange` on the desktop, the
in-process instance's new `apply` on orcad. The relay is unchanged.

Wire compatibility is Rule 1 shaped: a new optional method. A server that
predates it answers method-not-found, which the desktop IPC handler maps to an
error whose message is exactly `host-too-old`. Old clients never call it. The
method is deliberately absent from the mobile allowlist, and `aiVaultSearch`
stays out of the paired settings projection.

(cherry picked from commit 640c715fbd)

* fix(session-search): report a paired server without session search as host-too-old on status reads

(cherry picked from commit 1463e8a4bc)

* feat(settings): connected computers rows for session history indexing

Agent Session History now lists every computer that can hold an index --
this computer first, then each paired Orca server -- as one row with an
icon, a name, a single status line and its own switch. Indexing consent is
stated once above the list, and each row carries the switch for the host it
names, so turning search on for a server no longer means finding that
server's own settings.

Server rows poll aiVault.searchStatus on the same 2s/10s cadence as the
local one while the pane is visible, and report what the host actually
answered: Off, a sweep in progress, or an up-to-date count. A server that is
not connected stays listed but dimmed, with its last known switch position
and no claim about its index. A host that refuses the set call with
host-too-old flips to an update prompt that links to Remote Servers.

The old "Enable session history search" switch and the separate index-status
row are gone; their status copy moved to session-history-status-copy.ts and
their polling to use-session-search-status.ts, so every row shares one
message builder and one poll. Advanced > Delete index copy is unchanged and
still local-only, and a paired web client still sees this computer alone.

window.api.aiVault.setSearchEnabled is declared and bridged here but
implemented by the parallel backend PR.

(cherry picked from commit 0497e6fe93)

* fix(settings): treat a host-too-old status read as an outdated server

(cherry picked from commit a3d751f6e7)

* fix(settings): turn search off before deleting its index

Delete index cleared the index while search was on, so the host closed, removed and immediately reconstructed it and everything reindexed. Turn local search off first, then clear, so the rebuild only happens when the user switches search back on.

(cherry picked from commit 0515588681)

* feat(settings): product-facing copy for session search

Say search, not index or transcript; lead with what the user gets and
where it shows up; one plain privacy sentence; count sessions, not files;
drop the mechanics that change no decision (stop hint, SSH note, source
roots jargon).

(cherry picked from commit 267af1afb3)

* fix(settings): let Button and Collapsible own their spacing and type

* fix(settings): let Button and Collapsible own their spacing and type

* feat(session-search): report how many messages an index holds

The status contract gains an optional messagesIndexed, read from the store
beside the file-state counts and cached the same way, so a settings row can
say what is searchable rather than how many files were opened. Optional on
the wire: a paired server that predates the field degrades to a session count.

* feat(right-sidebar): let a caller open the session panel ready to type

showAiVaultSearch opens the sidebar on the vault tab and sets one flag. The
panel takes the flag, widens its scope to every computer, focuses the search
box and clears the flag, so a later remount stays where the user left it.

* feat(settings): redesign Agent Session Search for many computers

Renames the pane, splits the list into this computer and paired Orca
servers, and puts a count of what is on above it with a Turn on all that
skips offline and too-old hosts and keeps going past a host that refuses.
Consenting once persists a standing consent so a server that later becomes
reachable turns on without another dialog; turning one off by hand drops it.
Rows past the sixth fold away, ordered by what the user can act on. Status
sentences now say how much is searchable instead of Ready, and an off
computer says so with its switch alone.

* fix(settings): hide the fleet roll-up when no server is paired

With only this computer, the count, the Turn on all button and the two
subheads all restate the single switch under them. Show them once a paired
Orca server exists, which is the first point at which they say anything.

* fix(settings): turn session search on without a confirmation dialog

Each switch and Turn on all now act on the click. The dialogs restated the
row they sat under and stood between the user and a preference they can
reverse with the same control. Clearing search data keeps its dialog: that
one destroys something.

* fix(settings): say how many computers Turn on would reach

Drops the summary sentence: every row already states whether it is offline
or needs an update, so counting those again above the list said nothing new.
What is left is the one thing the list cannot say, the size of the action,
carried by the button's own label. With nothing left to turn on, the
standing consent speaks in its place, and only when it is armed.

* Revert "fix(settings): say how many computers Turn on would reach"

This reverts commit 42a4320ae1. The roll-up row's design is still open, so
the branch keeps the summary sentence and the plain Turn on all button until
it is settled. The dialog removal in 30b0786cc6 stands.

* feat(settings): offer one stateless Enable on all computers button

The row above the list is now just that button. It appears when a paired
server is reachable, new enough and off, acts on exactly those plus this
computer, and disappears when there is nothing left to do. What it offers is
read off the rows each render, so it cannot disagree with them.

Deletes the standing auto-enable consent with it: the persisted flag, the
code that armed and cleared it, the per-host memory of which switches the
user had touched, and the line that promised future computers would turn
themselves on. A preference that acts on hosts the user never sees is worse
than a button they press when they mean it.

* fix(right-sidebar): keep the focus-request callback out of render

React Doctor flagged the ref written during render; useEffectEvent is the
codebase's pattern for a latest-callback the effect reads.
2026-09-16 13:31:26 -04:00
Brennan Benson aee98ccaa0 fix(browser): make the browser identity one process-wide choice (#13822) (#20767)
* feat(browser): process-wide browser identity, chosen before ready

Electron resolves worker identity from a single process-global default, so two
coherent identities cannot coexist in one process. This makes clean/native one
app-wide decision read before `ready`, instead of a per-profile one that leaves
documents on one identity and every worker request on the other.

Both identities are load-bearing, measured across four origins at five reps:
the cleaned identity clears an embedded Turnstile widget and WhatsApp's browser
check where native is refused; native clears a full-page Cloudflare interstitial
that the cleaned identity never clears.

Base commit only: removing the per-profile field, its settings surface, and the
migration notice follow.

* test(browser): cover cross-context UA wire identity

* refactor(browser): make user agent identity app-wide

* test(browser): repair process identity wire fixture

* Fix browser identity startup migration failures

* WIP: rescue in-flight reduced-design work from a dead worker

Worker ctx_cb5b1262d7fe stopped ~2h ago mid-implementation (last heartbeat
2026-09-14T22:48:06Z) leaving this uncommitted. Committed unverified to make it
recoverable; not reviewed, not necessarily green.

* fix(browser): repair the rescued identity work so it typechecks

Finishes the interrupted edits in 7db9c54b54:

- browser-user-agent-migration-notice.ts was truncated mid-write; close the
  then() callback so the file parses.
- Register browser.identity.get/set in the generated RPC params catalog so the
  params type-parity gate is satisfied.
- Retire the persistence assertions for the superseded design: a
  migratedNativeProfileIds event map, a notice-acknowledgement clear, and a
  global persistence-failure accessor. Legacy userAgentMode bytes are retained
  now, so these assert retention plus a failed notice write still hydrating.
- The in-memory fs fixture threw a codeless ENOENT, which reads as "unreadable"
  rather than "missing" and made every identity write refuse. Carry the code.
- Use the segmented control's per-option disabled rather than adding a
  control-level prop it does not have.

* refactor(browser): make the identity store the only writer

The rescued work already serialized identity writes, but the writer lived beside the pre-ready reader, so nothing stopped a second caller from writing the record directly -- which is the shape of the bug this change set removes.

browser-identity-mode-record.ts is now read-only: record shape, path, parsing and the pre-ready synchronous read. browser-identity-mode-store.ts owns every mutation behind one queue, holds the snapshot and listeners, and derives restartRequired from appliedMode vs configuredMode rather than storing it. Consumers move to the store.

The two identity RPC methods also move out of browser-core.ts into browser-identity-rpc.ts: they read and write this host's own process identity rather than driving a page, and browser-core.ts was over its line cap. The generated params catalog is byte-identical.

* feat(browser): make resetting unhealthy identity data explicit and lossless

A corrupt or newer-version record left the identity unchangeable with no way out. An explicit reset now copies the old bytes verbatim to a fresh unique path before publishing a replacement, and refuses the whole operation if that backup cannot be written -- so the reset can never be the thing that loses the data. Nothing resets automatically.

Future-version data says update Orca rather than reporting corruption. Reset is opt-in via browser.identity.set and orca browser identity set --reset.

ProfileCreate and BrowserIdentitySet move to browser-identity-params.ts: both carry the per-profile to app-wide identity move, and browser-params.ts was over its line cap.

Also registers browser as a top-level CLI name so the Windows launch redirect covers it -- without it orca browser identity get boots the GUI and exits silently there -- and adds the canonical browser identity show alias the CLI vocabulary policy requires.

* feat(browser): advertise the identity capability only where it exists

browser.identity.v1 was static, so every host claimed it including one that never initialized the identity store, where both methods can only throw. It now follows the browser.headless.v1 precedent and is pushed at status time when the store is actually initialized.

Also covers the retired profileCreate userAgentMode field at the dispatcher rather than only at the schema, so an older client provably gets the changed-semantics rejection over the wire instead of a success with the field quietly dropped.

* refactor(browser): delete the identity write queue and guard backup uniqueness

The queue could not be falsified by any test: writeRecord is synchronous end to end, so two calls cannot interleave and removing serialization entirely left every store test green. Carrying machinery whose guard is unconstructible is what the design review told us to cut, so it is gone. If durable writes ever become async, serialization comes back with the change that makes it testable.

The test that claimed to prove serialization now states what it actually pins -- the later of two selections is the one that survives -- and the module doc no longer claims a queue that is not there.

Adds the guard that was missing on reset: two resets across separate launches must produce two distinct backups, each holding its own original bytes. Verified discriminating -- a fixed backup filename fails it.

* test(browser): guard the identity capability and harden two weak assertions

Pins the mixed-version guarantee that had no test: browser.identity.v1 is advertised when the identity store is initialized and absent when it is not. Verified discriminating -- advertising it unconditionally fails the test.

The profileCreate rejection test asserted ok:false against a runtime with no browserProfileCreate, so that assertion passed even when the retired field was accepted. It now stubs a working runtime method, making ok:false load-bearing, and asserts the runtime is never reached.

Removes the persistence fixture's dead failIdentityWrite branch on writeFileAtomically: nothing on that path calls it, so it implied a second write mechanism that does not exist. Failure is injected through node:fs, which is what the identity write actually uses.

* test(browser): classify the identity channels on the preview seam

The channel split is asserted total, so adding browser:identity:get/set left it
short by two. They manage the host's own process-wide user-agent choice rather
than acting on a guest the reader is looking at, so they sit with the session
and profile channels, not the preview tools.

* test(browser): audit the identity rig's global-fetch call sites

The wire probe server and CDP collector arrived with the cross-context coverage
and were never added to the audit list. The collector's two real call sites are
safe: the poll cancels its unread body and the version probe consumes it through
response.json(). Every hit in the probe server is inside an injected page or
worker script source string, not a call this process makes.

* fix(browser): strip an app name that contains a space

app.setName decides the app token in the user agent, and dev sets "Orca Dev".
The cleaner matched a single whitespace-delimited token, which cannot span that
space, so the replace failed outright and every dev build presented
"Orca Dev/1.4.203" on the wire — the exact token class that gets transplanted
sessions revoked.

Anchoring on the engine comment and consuming lazily up to Chrome/ removes any
number of app tokens. A user agent without that comment is returned unchanged
rather than mangled, because over-stripping is worse than under-stripping.

The function had no unit test at all; it was only exercised through the
real-Electron wire tests, which run with a single-token fixture name. That is
why this survived.

* fix(browser): anchor the cleaner on the gap before Chrome/

My first attempt anchored on the engine comment, which broke a startup fixture
whose platform comment is "(Test)" with no "(KHTML, like Gecko)" at all — the app
token survived and the ordering test went red.

Anchoring on the nearest ")" before Chrome/ and consuming only non-")" tokens
keeps the match inside that gap, so it handles a multi-word app name, a synthetic
platform comment, and an already-clean identity alike. A user agent with no such
gap is still returned unchanged.

The fixture shape is now a test case, since it is what caught the first attempt.

* test(browser): repair the cleaner's case table

A missing comma between two it.each elements was reformatted into an index
expression, collapsing the table so every case ran with undefined input.

* test(browser): make a CI-only capture failure diagnosable

This probe passes locally and fails on CI with an empty receipt set, an empty
CDP diagnostic list, and a fixture that still exits 0 — so the assertion message
carried nothing usable. Thread the fixture's own result and stderr into the
capture assertion so the next run says what the fixture actually did.

* fix(browser): let an explicit choice retire the migration notice for good

The retired per-profile userAgentMode bytes are retained on disk by design, so
every launch rediscovers them and re-arms the notice — including the launch
right after the user answers it, and every launch after that. Documented as
one-time, it was permanent.

The record already carries explicitSelection, which is exactly the fact that
should end the notice. Gate the mark at the single writer rather than deleting
the legacy key, so the retained bytes stay untouched and disk never claims a
notice is pending beside a choice the user already made.

The new test pushed the persistence suite past max-lines, so the in-memory fs
and module mocks move to a named fixture module and the retired-identity tests
move beside them in their own file.

* fix(browser): stop reporting an unhydratable profile as a retired choice

A profile that fails validation for a reason unrelated to identity — a non-UUID
id, a mismatched partition — armed both the notice and its degraded flag. Since
hydrateFromPersisted skips such entries silently and nothing ever repairs them,
the user got "an old browser identity choice could not be inspected" forever,
about a profile that never carried one.

Key the notice on the presence of userAgentMode instead, and use validation only
to decide whether the choice that was found is inspectable. Refusing to hydrate
an entry and finding a retired choice are now separate facts.

The old case table asserted the defect for null, 42 and 'broken', so it is
replaced by two tables stating the new contract rather than adapted to pass.

* fix(browser): stop rewriting worker requests for viewport emulation

A worker request carries no webContentsId, so it always took the session-wide
branch and picked up the mobile UA if any tab in the session had a mobile
preset. That made a single context disagree with itself: a desktop tab's shared
worker reported a desktop navigator.userAgent — the per-target CDP override
cannot reach a worker — while its fetches left as CriOS. It also leaked across
tabs, and closing the emulated tab silently reverted it.

On main the divergence was between contexts, each internally coherent. Making
one context internally inconsistent is worse by this PR's own standard, so
accept that viewport emulation reaches documents only. Workers keep the session
identity on the wire, which is the identity they report in JavaScript.

That left hasSessionMobileViewportIntent with no reader, so the map it fed and
its three accessors go too, rather than leaving a dead latch behind the guard.

The electron fixture models this rule in its own header hook, so its hook and
both mobile arms are rewritten around the invariant that each context's wire
identity equals the identity its own JavaScript reports — not adapted to keep
the old path list passing.

* test(browser): point the identity tests at keys and writers that exist

browserUserAgentMode appears in zero production files and zero commits on main;
`git log -S` finds nothing. The retired key is profile.userAgentMode inside
browser-session-meta.json. Two tests were built on the invented one.

The global-settings test is deleted rather than repointed: no browser identity
key has ever lived in global settings, and stripRetiredGlobalSettings strips
only three unrelated keys, so the test asserted that an arbitrary unknown key
survives an object spread — a fact about the normalizer, not about identity.

The ready-phase test asserted on writeFileAtomically while the identity store
writes through writeFileDurableSync, so it could not go red for the write it
existed to forbid. It now watches the real writer, matched on the record path so
an unrelated durable write cannot fail it for the wrong reason, and the invented
settings key is gone from the Store mock.

Proven by ablation: injecting a byte-identical rewrite of the record into ready
composition leaves every snapshot and record assertion green and is caught only
by the new assertion, while writeFileAtomically is never called.

* fix(browser): let an unavailable process identity reject instead of throwing

installBrowserSessionPartitionPolicies returned Promise<void> without being
async, and configures the user agent policy before any suspension point.
getBrowserProcessUserAgentIdentity throws when the process identity was never
initialized, so that throw escaped synchronously past every caller's handler:
`void install(...).catch(...)` in the registry, and a bare `void install(...)`
in the route policies, which has no handler at all.

Bookkeeping must never gate a user action. Session startup would have died on a
failure its callers were already written to absorb and report.

* docs(browser): scope the meta-store claim about dropped legacy keys

The comment said persistMeta drops legacy keys on the next write because the
loader no longer carries them. That holds for the top-level userAgent keys it
describes, but not for the retired per-profile userAgentMode: it sits inside
each BrowserSessionProfile in `profiles`, which is carried through untouched, so
those bytes survive every write.

Retaining them is deliberate — it is what makes rollback and data-loss machinery
unnecessary, and the startup notice keys on their presence — so the comment read
as broader cover than it provided, in the one place someone would look before
deciding it was safe to strip them.

* test(browser): pin the unmapped-webContents path beside an emulated tab

A popup carries a webContentsId that maps to no registered tab, so it resolves
through the same branch as a worker request that carries none at all. The branch
already handled both, but only the absent-id case was covered.

* test(browser): make the ordering fixture exhibit a multi-word app name

This file sets the dev app name to "Orca Development" and then used a
single-token user agent fixture, so it set up the multi-word scenario and used a
fixture that could not exhibit it — which is how the multi-word app-name leak
got through. The fixture now carries a two-word app token, matching what
app.setName produces in dev, and the assertion names both words: a single \S+
match would leave "Orca" on the wire and still pass a one-token check.

* test(settings): cover the local branch of the browser identity setting

The only existing test covered the remote-host branch. The local branch — load,
select, refused write, and reset-required — had none, and that is the path the
retired-identity notice sends users down to make the choice that retires it.

Covers the selected-mode render, the commit that reports restartRequired, a
refused write surfacing its message without showing the mode as changed, and the
reset-required state offering no control.

* test(browser): run the real registry path in the ready identity pin

The test stubbed browser-session-startup and browser-session-registry, which are
the one ready-phase path that can write the identity record, so the record
content assertion could not fail for the write it existed to forbid.

Both are now real. Only the pieces hanging off the identity path are stubbed —
partition policies, route sessions, cookie staging, webauthn — so the meta load,
the retired-choice inspection, the identity store and the durable write all run
for real against temp directories. The canonical path mock moves to
persistence/loading-store/user-data-path, which is where the registry reads it;
mocking persistence alone left the registry pointed elsewhere. The active
profile directory is now a real temp dir, so the seeded browser-session-meta.json
is actually found — against the old /test-profile literal the meta load found
nothing and the whole exercise would have been vacuous.

A third case proves the path is live: with no explicit choice, the same retired
profile arms the notice through ready and lands migrationNoticePending on disk.
The two authority cases assert the opposite, that an explicit choice leaves the
record untouched.

initializeBrowserSessionsForApp latches on module state, so each case resets
modules and imports ready dynamically.

Ablated: disabling the explicitSelection gate turns both authority cases red on
the record content assertion while the arming case stays green.

* fix(browser): reject an unrecognized identity mode at the IPC door

normalizeBrowserUserAgentMode turned any unrecognized value into 'clean', so the
IPC door reported success for a mode it had quietly replaced, while the RPC door
validates against z.enum(['clean', 'native']) and rejects. One concept answered
an unknown value two different ways, and a future mode name was silently
downgraded rather than refused.

The handler now rejects, which is what the RPC door does and what the renderer
already handles — its catch puts the message in the error slot. Returning a
result instead would have meant inventing a fourth error code for a case no
legitimate caller can reach.

normalizeBrowserUserAgentMode had no other consumer, so it goes with the change:
leaving a coercion helper called "normalize" in shared/ invites the behaviour
straight back in.

* fix(settings): name the reset command where identity data is unusable

When configuredMode is null the setting says identity data must be reset
explicitly and then offers no control, because the reset overwrites data that
may belong to a newer Orca. The only escape is the CLI, which the message never
named — so it told the user to do something and gave them no way to do it.

Copy only: one line naming the command, no control and no destructive action in
the UI. The command goes in a new key beside the existing sentence rather than
expanding its default, which keeps the already-translated string valid.

No en.json entry: this component has no catalog entries for any of its keys, so
English resolves from the call-site defaults and adding one only for the new key
would be inconsistent with its siblings.

* fix(i18n): add the browser identity keys to the localization catalog

* fix(i18n): regenerate the runtime-required English catalog

* fix(browser): attach nested CDP targets paused before enabling Network

An OOPIF or dedicated worker was reached only through Target.targetCreated plus
an explicit attachToTarget, which never pauses the target. The frame could issue
its subresource fetch before Network.enable took effect, so the capture came back
empty and the cross-context assertion failed under CI load.

Re-arm auto-attach on each attached session, filtered to nested target types, so
an OOPIF or worker arrives waiting for the debugger and its enables are ordered
ahead of the resume. Drop the explicit attach, which is now both redundant and
the racy path.

* fix(settings): localize the browser identity search keywords

* fix(browser): await route policy setup

* fix(browser): satisfy strict static analysis

* test(browser): update live identity fixture API

* test(browser): preserve native UA in live probe

* fix(browser): close the open review findings on the identity revert

- drop a stray JSDoc left over from the removed per-profile setting
- leave user agents without a Chromium engine comment byte-identical
  instead of anchoring the app-token strip on the OS comment and
  destroying a real engine token
- localize the browser identity unavailable error
- correct the worker comment: only shared and service worker requests
  carry no webContentsId, so emulation still reaches dedicated workers
- retire the session user agent policy when a profile is deleted

* test(browser): model a real Electron fallback in the startup UA fixture

The ordering fixture carried no "(KHTML, like Gecko)" engine comment, a
shape app.userAgentFallback cannot actually produce. That unfaithfulness
was what made the old over-stripping look correct, and it broke once the
cleaner started leaving non-Chromium identities alone.

Add the engine comment, keeping the two-word "Orca Development" app token
so the multi-word leak this test exists to catch is still caught. Both
assertions are unchanged.
2026-09-16 10:31:01 -07:00
Jinwoo Hong bdb18003e0 test: add accumulated-workspace terminal typing reproduction (#20934)
* test: reproduce accumulated-workspace typing latency through real PTYs

* test: make the bench harness self-checks falsifiable

Review found four assertions that could not fail and one fixture gap:

- `missingPtyArrivalCount`/`missingEchoCount` were hardcoded `0` and
  `validateExpectedSeqs` throws before them, so every assertion on them
  was vacuous and every report read `0`. The throw is the real guard and
  is already covered; drop the vestigial fields.
- An absent status controller returned an all-zero result, which satisfied
  its own accepted-equals-generated equality. Assert presence first.
- The byte-pacing control had only an upper bound, so a generator emitting
  no stream bytes passed. Add the lower bound.
- `lineageEvery: 1` built zero lineage: no ordinal satisfies
  `% 1 === 1`. Offset the interval and cover the densest setting.
- The documented control command never set ORCA_TYPING_BENCH, so it
  skipped instead of running.
2026-09-16 13:10:46 -04:00
Jinwoo Hong 7c4325457c fix: name the notification project by the worktree's own host (#20958)
* fix: name the notification project by the worktree's own host

STA-4343: a worktree id is `repoId::path` with no host component, so the
local host and an SSH host publish one id for two different workspaces.
The id-keyed worktree map is first-wins and the repo map is last-wins, so
a colliding workspace could be labelled with the other host's project and
branch. Resolve the owning host first and name nothing when hydrated
ownership cannot prove one; an omitted label beats a wrong one.

Only the collision branch changes: a single-row id still takes the same
map lookups it did before.

* fix: name the project by its host when a repo id spans hosts

A repo id is registered per host, so two hosts can hold one id at
different paths. Their worktree ids are then unique, so the single-row
path skipped host resolution and fell back to the id-keyed repo map,
which is last-wins — a local worktree got the ssh project's name.

Gate the repo lookup on the id actually being ambiguous: one owner keeps
the plain map lookup and its cost, and only a spanning id resolves the
owning host, naming nothing when ownership is unprovable.
2026-09-16 12:51:17 -04:00
Jinwoo Hong a01027697c feat(session-search): enable indexing on paired servers from a client (#20886)
* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

* feat(settings): live index status, enable confirm, advanced delete

* feat(session-search): search every computer from the history panel

The panel's "All computers" scope produced no request: the hook parsed the
scope into a single host id and stopped when that was null, so the panel
answered "Choose one computer to search its sessions." The desktop already
merges every enumerated host behind `aiVault:searchSessions`, so pass the
scope straight through and stamp each hit with the host it came back on.

Hosts the merge could not search are named under the results header with a
short reason, since a silent partial answer reads as "no such session".

(cherry picked from commit c6b9179316)

* feat(session-search): enable indexing on paired servers from a client

Adds `aiVault.setSearchEnabled` so a desktop can turn a paired Orca server's
transcript index on or off and have the server apply it without a restart.

The runtime method refuses any caller without a `pairedDeviceId` with a
`forbidden`-class error, writes the whole resolved policy through the runtime
store so retention rides along untouched, then reaches the index through a
host-supplied hook: `applySessionSearchSettingsChange` on the desktop, the
in-process instance's new `apply` on orcad. The relay is unchanged.

Wire compatibility is Rule 1 shaped: a new optional method. A server that
predates it answers method-not-found, which the desktop IPC handler maps to an
error whose message is exactly `host-too-old`. Old clients never call it. The
method is deliberately absent from the mobile allowlist, and `aiVaultSearch`
stays out of the paired settings projection.

(cherry picked from commit 640c715fbd)

* fix(session-search): report a paired server without session search as host-too-old on status reads

(cherry picked from commit 1463e8a4bc)

* fix(settings): let Button and Collapsible own their spacing and type
2026-09-16 12:50:59 -04:00
Jinwoo Hong 8153ec2306 feat(session-search): search every computer from the history panel (#20885)
* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

* feat(settings): live index status, enable confirm, advanced delete

* feat(session-search): search every computer from the history panel

The panel's "All computers" scope produced no request: the hook parsed the
scope into a single host id and stopped when that was null, so the panel
answered "Choose one computer to search its sessions." The desktop already
merges every enumerated host behind `aiVault:searchSessions`, so pass the
scope straight through and stamp each hit with the host it came back on.

Hosts the merge could not search are named under the results header with a
short reason, since a silent partial answer reads as "no such session".

(cherry picked from commit c6b9179316)

* fix(settings): let Button and Collapsible own their spacing and type
2026-09-16 12:40:42 -04:00
Jinwoo Hong 3631a1e77f feat(cli): show orca search now the settings toggle ships (#20677)
* feat(cli): orca search over the agent session index

`orca search <query>` calls PR 5's `aiVault.searchSessions` over the CLI's
existing runtime RPC, against the host `--environment` / `--pairing-code`
selects and no other. `orca search --index-status` calls `aiVault.searchStatus`.
It is the proof the contract works with no panel.

Every flag maps onto a contract field and nothing else: `--scope`, `--fresh`,
`--limit`, `--cursor`, repeatable `--agent` and `--path`, `--since`, `--sort`,
`--debug`, `--json`. No fan-out, no merged output, no `--host`.

One command rather than a `search status` subcommand: the query is a bare
positional, so `orca search status` could not be told apart from searching for
the word "status". `--status` is unavailable because `orchestration task-list
--status <state>` already owns the name as a valued flag.

No new runtime capability. PR 5 decided an explicit `method_not_found` refusal
maps to `unavailable/no-service`, so reusing `createSessionSearchClient` gives
an old host a plain "this host runs no session search service" answer at exit 0
instead of a raw JSON-RPC error.

`CommandSpec.repeatableFlags` scopes repeatability per command, because
`--agent` must repeat for search and stay single-valued for `worktree create`.
`help.ts` sat exactly at max-lines, so `skills-command-flag-help.ts` becomes
`command-scoped-flag-help.ts` carrying both tables at the same call-site size.

* refactor(cli): drop the search type assertions main's casting gate now rejects

Main gained a `consistent-type-assertions: never` scan in the changed-code gate
after this branch was cut, and it reported twelve assertions in the new files.

The four in the argument parser were avoidable. `readEnum` now keeps the value
`find` returns, which already carries the narrow type, and the agent filter goes
through an `isAiVaultAgent` predicate over a `Set<string>` instead of widening
the agent tuple.

The test now narrows the printed envelope by shape and re-reads the printed
result through `AiVaultSearchResponseSchema`, so the JSON assertions are checked
rather than claimed, and the flag table is typed so its callback needs no cast.
One assertion is left, for the structural fake client, with the SAFETY rationale
AGENTS.md requires.

* fix(cli): sanitize host strings and scope pre-command repeatable flags

Route every host-supplied string the search formatter prints through the
escape stripper, and resolve the repeatable-flag set from the command
tokens ahead when a flag sits before the command.

* refactor(cli): resolve repeatable flag rules once per command

* fix(cli): clarify session search availability and SSH scope

* feat(cli): hide orca search until the settings toggle ships

`orca search` stays dispatchable but leaves every discovery surface: root
help, group help, unknown-command suggestions, and `agent-context --json`.
`buildAgentContext` did not filter hidden specs, so it also stops leaking
the hidden `terminal stop`.

* feat(cli): show orca search now the settings toggle ships

* docs(skills): teach the orca-cli guide the search command

One section: what orca search covers, one host at a time, scope and
narrowing flags, index status before searching, and that a human turns
search on.

* docs(skills): shape the search section like the other command sections
2026-09-16 12:29:35 -04:00
Jinwoo Hong 46ed53b88a feat(session-search): merge all-computers search across hosts (#20670)
* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

* feat(settings): live index status, enable confirm, advanced delete

* fix(settings): let Button and Collapsible own their spacing and type
2026-09-16 12:29:11 -04:00
Jinwoo Hong f6197dbd36 fix(ai-vault): index Codex agent replies whose content blocks are typed Text (#20763)
Codex 0.153+ writes paginated rollouts whose completed agent messages carry
content blocks typed `Text`. The transcript reader matched block types
case-sensitively, so every assistant turn from those sessions was dropped
before it reached the search index while user turns and tool output were
kept. Match case-insensitively and bump the index schema so existing
indexes are rebuilt with the replies present.
2026-09-16 12:28:12 -04:00
Jinwoo Hong 0d2f7bcea3 fix(session-search): index OpenCode SQLite sessions (#20870)
* feat(session-search): index OpenCode SQLite sessions

OpenCode sessions live in one SQLite database read on a worker thread, and
the worker only ever answered with the newest few messages for the panel
preview. The parser therefore published nothing over the transcript channel,
so the search index wrote a placeholder row for every OpenCode candidate and
no OpenCode message was ever searchable.

Adds a `capture` request to the worker protocol that returns the session and
every text part of every user/assistant turn from one open of the database.
The agent parser asks for it whenever a sink is listening, so OpenCode joins
the whole-document sources on the same path as Grok, Cursor and Gemini. The
placeholder path (`parserPublishesMessages`, `noteUnreachableParser`) is gone;
an OpenCode read that fails now fails like any other file.

Bumps the index schema so existing indexes drop their placeholder rows, and
adds `sessionsByAgent` to the index status, which is the count that made this
bug visible.

* test(session-search): assert every source speaks, not every agent

OpenCode has two storage shapes, so asking only that some OpenCode session
published messages was satisfied by the legacy JSON fixture while every
SQLite session in the vault stayed silent. Assert per discovered source and
keep the agent-coverage check beside it.

* feat(session-search): capture OpenCode tool and reasoning parts

Text parts alone left OpenCode behind every file-based provider: a command
someone ran, what it printed, and the model's reasoning were all unsearchable.

Widens the capture query to text, reasoning and tool parts. Reasoning folds
into the turn's own words, the way the shared block list already treats a
thinking block. Each tool part becomes one `tool` message carrying the call
line and what came back, built with the same `toolCallText` every file
provider uses; OpenCode's `filePath` is renamed to the `file_path` spelling
that list knows, so a call is findable by its file argument.

Adds a decoded-size ceiling beside the existing part ceiling. It is the bound
a non-streaming source needs and a streaming one does not: a JSONL provider
publishes each message as it reads it, while this one holds a whole session
before posting it across the worker boundary. Neither ceiling truncates; both
fail the read so it is retried and surfaces.

* fix(ai-vault): fail an OpenCode capture it cannot read the message parts of

`readOpenCodeSessionMessages` returned an empty list when the message-part
schema probe failed. The sink-aware reader treats that as a complete read,
so the consumer committed nothing and marked the source `current`: the
session stayed out of the index with nothing on its row to say why and no
retry. The part limit a few lines below already throws for exactly this
reason, so the two now agree.

The preview path is unchanged and still degrades to no messages, which is
what a list read should do.

Also throws from the fixture's `appendOpenCodeSqliteTurn` when the session
id names no row, instead of falling back to the fixture epoch and appending
orphan messages a test would then assert over.
2026-09-16 12:27:26 -04:00
Jinwoo Hong b997fcc77a fix(session-search): try phrase and AND routes for prose queries before OR (#20754)
* fix(session-search): try phrase and AND routes for prose queries before OR

An exact sentence pasted out of a transcript was not returned. The route
ladder only ran the phrase and AND rungs for a literal-looking query, so
prose fell straight to OR, where the sentence's common words filled the
candidate limit with recent sessions and the old session holding the
sentence never reached ranking.

The planner now carries a `phrase` candidate: the query's tokens in order
with stop words kept, which is what the sentence is actually indexed as.
The ladder runs phrase then AND over those tokens for every query of two
or more tokens. A one-token query still takes the rung only when it
looked literal. `incomplete` is reported by the rung that answered rather
than accumulated across every rung tried.

* fix(session-search): mark a snippet with the route that retrieved it

A phrase hit was highlighted with the OR expression over the stop-word
stripped terms, so an exact sentence rendered as scattered bold words with
its stop words plain. The snippet now uses the expression the route
matched by: one run for a phrase, every typed word for AND, the terms for
OR.

* fix(session-search): repair a prose phrase without dropping its stop words

Typo repair re-planned the query from `plan.body`, which prose has already
had its stop words removed from. `relay is droppng frames` therefore came
back as the plan for `relay dropping frames`, and the phrase rung searched
for a sentence nobody wrote: the transcript holds `relay is dropping
frames`, so the exact match fell through to AND.

The repair now maps over `plan.phrase`, the tokens as typed, and re-plans
from those. Only terms the body holds are offered to the corrector, so a
stop word is still never repaired, and the re-plan recomputes the body
from the corrected sentence exactly as before.
2026-09-16 12:27:05 -04:00
Jinwoo Hong dbd750f64d fix(session-search): keep the index status honest while a sweep has a backlog (#20753)
* fix(session-search): keep the index status honest while a sweep has a backlog

A pass stops reading at its wall-clock deadline and records nothing about
the candidates it never opened, which is correct: being owed a read is a
fact about the row, not an entry in a queue. But a candidate the opening
sweep never reached has no row at all, so the store's `due` count cannot
see it. The sweep still reported `completed`, the indexer stamped
`lastSweepCompletedAt`, and `status()` answered `current` with a backlog
of thousands: "Up to date - 130 files indexed", then 630, then more.

The read loop now counts what it decided was owed and did not read and
hands the number back as `left`; the pass propagates it; the indexer
holds the last pass's count, adds it to `filesDue`, reports `indexing`
while it is non-zero or a sweep is owed, and no longer stamps a sweep
the deadline cut short as complete.

* fix(session-search): only an unread backlog keeps the phase at indexing

An armed cadence sweep on a drained index is not a backlog, so it no
longer flashes the pane to indexing with nothing due.

* fix(session-search): stop counting a deferred `due` row twice in filesDue

`status()` reports `filesDue` as `stateCounts().due + left`. The read loop
incremented `left` for every candidate the deadline cut off, including one
whose row already said `due` — and that row is what `stateCounts().due`
counts. A sweep that ran out of time therefore reported each already-due
transcript twice.

`left` now skips a deferred candidate whose row is already `due`. A
candidate with no row, and a `current` row whose file moved, still count:
those are the backlog no query can see, which is why `left` exists.
2026-09-16 12:26:47 -04:00
Jinwoo Hong a7e34d5695 feat(session-search): add panel search and opt-in consent (PR7) (#20580)
* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

* fix(lint): drop the type import #20898 left behind in the windowing test

main's tip fails `typecheck` and `static analysis` on
`NativeChatMessageList.windowing.test.tsx`: #20898 moved the growth/append
suite into its own file and took the last use of `NativeChatMessage` with
it, leaving the import. Every open PR reds both jobs through the merge ref,
so this rides the first branch that has to merge main in.
2026-09-16 12:12:59 -04:00
Jinwoo Hong f85d2bf6ad fix(ai-vault): say OpenCode-in-WSL is not searchable from Windows yet (#20971)
The scan issue for an OpenCode database on a \\wsl.localhost share read as an
error with an instruction the user cannot follow. It now surfaces on the Agent
Session Search settings page under a computer row, where it belongs as a known
limitation, so the WSL branch now reads 'OpenCode sessions inside WSL can't be
searched from Windows yet.'

Copy only: the issue keeps kind 'scope' and its path, every other branch is
untouched, and discovery, the WSL gate, and the busy-timeout behavior are
unchanged.
2026-09-16 12:04:16 -04:00
Jinwoo Hong 3e5eb0329a feat(cli): orca search over the agent session index (#20514)
* feat(cli): orca search over the agent session index

`orca search <query>` calls PR 5's `aiVault.searchSessions` over the CLI's
existing runtime RPC, against the host `--environment` / `--pairing-code`
selects and no other. `orca search --index-status` calls `aiVault.searchStatus`.
It is the proof the contract works with no panel.

Every flag maps onto a contract field and nothing else: `--scope`, `--fresh`,
`--limit`, `--cursor`, repeatable `--agent` and `--path`, `--since`, `--sort`,
`--debug`, `--json`. No fan-out, no merged output, no `--host`.

One command rather than a `search status` subcommand: the query is a bare
positional, so `orca search status` could not be told apart from searching for
the word "status". `--status` is unavailable because `orchestration task-list
--status <state>` already owns the name as a valued flag.

No new runtime capability. PR 5 decided an explicit `method_not_found` refusal
maps to `unavailable/no-service`, so reusing `createSessionSearchClient` gives
an old host a plain "this host runs no session search service" answer at exit 0
instead of a raw JSON-RPC error.

`CommandSpec.repeatableFlags` scopes repeatability per command, because
`--agent` must repeat for search and stay single-valued for `worktree create`.
`help.ts` sat exactly at max-lines, so `skills-command-flag-help.ts` becomes
`command-scoped-flag-help.ts` carrying both tables at the same call-site size.

* refactor(cli): drop the search type assertions main's casting gate now rejects

Main gained a `consistent-type-assertions: never` scan in the changed-code gate
after this branch was cut, and it reported twelve assertions in the new files.

The four in the argument parser were avoidable. `readEnum` now keeps the value
`find` returns, which already carries the narrow type, and the agent filter goes
through an `isAiVaultAgent` predicate over a `Set<string>` instead of widening
the agent tuple.

The test now narrows the printed envelope by shape and re-reads the printed
result through `AiVaultSearchResponseSchema`, so the JSON assertions are checked
rather than claimed, and the flag table is typed so its callback needs no cast.
One assertion is left, for the structural fake client, with the SAFETY rationale
AGENTS.md requires.

* fix(cli): sanitize host strings and scope pre-command repeatable flags

Route every host-supplied string the search formatter prints through the
escape stripper, and resolve the repeatable-flag set from the command
tokens ahead when a flag sits before the command.

* refactor(cli): resolve repeatable flag rules once per command

* fix(cli): clarify session search availability and SSH scope

* feat(cli): hide orca search until the settings toggle ships

`orca search` stays dispatchable but leaves every discovery surface: root
help, group help, unknown-command suggestions, and `agent-context --json`.
`buildAgentContext` did not filter hidden specs, so it also stops leaking
the hidden `terminal stop`.
2026-09-16 12:03:38 -04:00
Jinwoo Hong dec0e2cd56 feat(session-history): add local search settings and index controls (#20582)
* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(settings): live index status, enable confirm, advanced delete

* fix(settings): let Button and Collapsible own their spacing and type
2026-09-16 11:59:22 -04:00
github-actions[bot] 0b28d354fe Update README downloads badge 2026-09-16 12:37:45 +00:00
Brennan Benson 291b4ddd6f feat(agent-status): route structured sessions through canonical ownership (#20718)
* feat(agent-status): route structured status through canonical ownership and fence child lifetimes

Restacked onto the canonical store and child-work contract. Completing that
restack drops the `reopenStructuredParent` mutation flag this change had
carried, along with its contract field, its codec branch, and its single
call site in structured ingest, which passed a hardcoded `true`.

The flag was a narrow escape hatch from the absolute `tombstones.has(...)`
rule that governed parent upserts in this branch's original base. The
canonical store replaces that rule with a revision envelope, because a
bounded store compacts tombstones away and a presence-based guard silently
stops fencing once one is evicted. With the envelope deciding the outcome,
the escape hatch has nothing left to escape from, so removing it changes no
production behaviour.

`agent-status-store-reopen.test.ts` is rewritten against the envelope: the
reopen case now pins that an unflagged republication succeeds while replay
from before the reopen stays fenced even after the parent tombstone is
compacted away, and the second case pins where the guard genuinely bites —
a republication inside the removing mutation itself, for every subject kind.

* fix(agent-status): re-admit unchanged structured owners after teardown

* fix(agent-status): clear anti-slop object-param and Reflect.apply findings

- agent-status-store-byte-budget.ts: type the byte-budget helper's
  record parameter as the union of what its call sites actually pass
  (the snapshot header plus each store entity record) instead of the
  broad `object`.
- server-structured-canonical-status.test.ts: replace `Reflect.apply`
  with a typed, explicitly-bound call that models a caller at an
  untyped boundary omitting the trusted owner subject.

* docs(agent-status): drop the 2A progress doc from docs/reference

docs/reference/ holds implementation detail, not rollout progress. The
canonical-boundary notes move to the effort's working directory; the
agent-status-store status section keeps the boundary statement and loses
the now-dangling link.

* fix(agent-status): mint the canonical epoch on first use, not at construction

The hook server's canonical store was built in an instance-member initializer, so
constructing AgentHookServer — which happens at import time for the module
singleton — demanded a live randomUUID. Any importer that stubs node:crypto threw
'Invalid agent status store epoch' before a single test ran.

The store is now created on first canonical access and reset by dropping it, so
construction owes nothing to a crypto implementation and the epoch still rotates
per authority incarnation.

* fix(agent-status): drop the orphaned snapshot budget and a duplicated pane guard

Two leftovers from the canonical-store routing change:

agent-status-store-snapshot-budget.ts lost its only caller when the store state
switched to agentStatusStoreFitsByteBudget. Nothing in the repo imports it now,
so the module goes with the caller it existed for. The replacement is not a
straight copy: it only memoises a record's measured size once the record is
frozen, so a still-mutable record can no longer return a stale byte count.

persistedStructuredWorkerPaneKeyIsValid repeated its public-pane-key rejection
verbatim three lines below the first one. The tests covering that rejection pass
on the first occurrence alone, so the second decided nothing and only obscured
which predicate was load-bearing.

* fix(agent-status): stop a failed structured publish from latching as owned

Three defects found reviewing the structured routing path.

combinedStatusEntries defaulted a missing listing order to 0, but the counter it
compares against starts at 1, so any unordered row sorted above every ordered
one. Unknown order now sorts last.

The owner map recorded a session as owned before the sink ran. A publish that
threw therefore left matchesLocation reporting an owned location for a row that
was never written, and the unchanged-projection path — the only thing that would
re-offer it — stopped. The address still has to survive a throw so teardown can
forget a row that did land, so the two facts are now separate: the address is
recorded up front, and only a publish that returned marks the row as landed.

The reopen test claimed the revision envelope rather than the tombstone fences a
stale replay. It cannot tell: transport consecutiveness, the parent-revision
validator and the tombstone guard each refuse that replay alone, and ablating any
two leaves the test green. It now asserts the outcome and says so.
2026-09-16 01:20:58 -07:00
Brennan Benson 170ebce1f2 fix(ci): run static analysis for every tree the repo-wide audits scan (#20918)
A mobile-only diff is desktop-irrelevant, so should_run was false and every PR check skipped -- including the audits that do lint mobile/. The violation then landed on main and failed the same gate on every later PR's merge ref. Derive the trigger from the audit commands' own scan roots so the two cannot drift.
2026-09-16 01:13:06 -07:00
Jinjing 15cac68802 Native chat keeps scrolling to bottom (#20898)
* fix(native-chat): prevent auto-scroll when transcript is hidden

Stop following new messages to bottom when the chat view is not visible
(e.g., in an inactive tab). Restore scroll position when the transcript
becomes visible again.

* fix(native-chat): preserve reader scroll offset when transcript is revea

When a reader scrolls away from the latest messages and the chat tab becomes hidden, save their scroll position. If messages arrive while the tab is hidden, don't auto-scroll. When the tab is revealed, restore the saved offset instead of jumping to latest, preserving their reading context across hide/reveal cycles.

* refactor(native-chat): extract growth-append tests and status component

Move transcript growth/append test suite to dedicated growth-windowing.test.tsx file for better organization. Extract status rendering logic (errors, retry, background tasks) from NativeChatStructuredSession into NativeChatStructuredSessionStatus. Fix scroll offset preservation in test harness when transcript visibility toggles.

* refactor(native-chat): remove retry UI

Remove unused retry functionality for failed message delivery from the native chat status component. The retryableOutboxEntry state is no longer managed, so the retry button and associated handling can be safely removed.
2026-09-16 00:44:11 -07:00
OrcaWinandm4air 07b7687a2e fix(sidebar): keep the Projects filter when a project is added (#20987)
Adding a project wiped the Projects filter: both reveal paths made the
new project visible by clearing filterRepoIds outright, so a user
filtered to A and B was dropped back to every project.

The filter is an allow-list, so revealing a repo only needs that repo
added to it. revealRepoInProjectFilter widens the selection instead,
and no-ops while the filter is off, where adding an id would turn
"show everything" into "show only this project".

STA-7588

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-16 00:37:01 -07:00
Neil d62328aa4d fix(codex): remove redundant Windows hook launcher for Unicode profiles (#20952)
* fix(codex): reuse the Windows hook shell for Unicode profile paths

* test(codex): register Unicode hook tests in Windows CI

* test(codex): pin trust hash replacement during Windows upgrade

* test(codex): retry transient Windows teardown locks
2026-09-16 00:30:52 -07:00
Jinjing 78609330d1 Fix browser viewport presets incorrectly scaled by UI zoom (#20962)
* Fix browser viewport presets scaled incorrectly by UI zoom

Browser viewport presets must remain in window DIP (native) coordinates
but scale in CSS pixels as UI zoom changes. Store preset dimensions as
CSS variables in DIP, then divide by the live UI zoom factor in the
stylesheet. Also consolidate zoom factor calculations across the app
to use a shared `uiZoomFactorFromLevel()` function and add
`windowDipToCssPx()` for converting native coordinates to CSS pixels.

* Move viewport preset zoom compensation to CSS class

Inline width/height styles outrank class rules, preventing the zoom
compensation from applying. Using a class rule ensures the viewport
scales correctly as the UI zoom factor changes.
2026-09-16 00:16:56 -07:00
Neil e39b432c40 fix(editor): preserve Markdown scroll after image layout (#20956)
* fix(editor): preserve markdown scroll after image layout

* test(editor): harden scroll regression cleanup and geometry checks
2026-09-16 00:05:29 -07:00
OrcaWinandm4air 16ac9018db docs: remove unavailable diff shortcuts (#20974)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-15 23:44:23 -07:00
Brennan Benson c702e77bc7 Stop reading the terminal arguments field on the structured chat route (#20944)
* fix(native-chat): stop reading the terminal arguments field on the structured chat route

Setting Claude's Arguments to "--dangerously-skip-permissions --model Opus" made
every new Claude tab open in the old terminal-backed chat instead of the new
structured one, with nothing on screen to explain why. Removing "--model Opus"
fixed it.

The cause was a whole-string comparison: the configured arguments were checked
against a single blessed value per agent, so any added token at all — including
one the agent supports — stopped the string matching and the launch was demoted.

Structured chat does not run the interactive CLI. It drives Claude through the
Agent SDK and Codex through app-server, and those take narrower option sets that
are versioned separately from the CLI's, so one free-text field cannot have a
guaranteed meaning for all three. The structured route now reads only what it can
actually honour: a replaced launch command, or a launch that names its own working
directory. Terminal launches still apply the field exactly as before.

Permission posture no longer travels as a raw flag. It is derived from the
resolved launch arguments, which is the same fact a terminal launch acts on and
which falls back to the default Orca ships when the field was never touched, so
bypass stays on by default and Manual is still honoured. Claude gets the SDK's
typed permissionMode and allowDangerouslySkipPermissions at query start; Codex
gets its bypass flag placed before the app-server subcommand. Both are re-derived
per acquisition beside the auth policy and environment overlay rather than stored
in the session record, so nothing can disagree with the setting.

Codex also loses the --profile, --add-dir and -c passthrough that reached
app-server through that field. Only the permission posture comes back.

* test(native-chat): pin routing authority on the narrowed feasibility input

The routing-authority pin still named the old bundled blocker and built its
"customized" fixture out of the arguments field, which is no longer a feasibility
input. Both are now the launch command, and arguments and environment are
customized on both passes of the loop, so the flag handed to the shared resolver
tracks the command alone — a caller that resumed reading either one fails here.

No case is dropped and no assertion is relaxed: the blocker list is still
exhaustive and every caller must still honour a refusal from the shared resolver.
2026-09-15 23:38:04 -07:00
Jinwoo Hong f78483ec29 refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6, migration 1) (#20954)
* test(mobile): record the three step-6 families at the pin, and record a stream listener that dies

Step 6 migrates the requests step 4 left behind because they share an effect with a
`client.subscribe`. This records them first, from the pinned baseline, so the refactor that
follows has a parity oracle.

Three new families, one adapter module each:

- `session.native-chat-page` — the older-history page. The read is a callback, but only the
  mount effect's `nativeChat.subscribe` arms what it pages against, so the frames are the setup:
  the snapshot's `beforeOffset` decides whether the request carries a cursor or asks for a
  growing tail. A cutover and a second snapshot pin the reconnect replay merging into paged-in
  history instead of collapsing the window.
- `notifications.desktop-stream` — the desktop notification socket: the subscribe, the catch-up
  read its `ready` arms, the tray dismissals its events drive, and the server unsubscribe the
  disposer sends. Split in two so the base scenario's matrix sites all have partition-stable
  params: a variant that answers the second `ready` differently leaves the unsubscribe carrying
  the first subscription id, which the base's scripted params could not assert.
- `session.terminal-gesture-input` — the debounced gesture flush and the menu's clear-buffer.
  Neither rides a subscription; a mount holding no terminal ref reaches both.

The engine change is what makes the first two recordable at all. `ScriptedRpcTransport.frame`
now returns what the product listener threw instead of throwing it on, and the runner records it
as a `stream-listener-crash` effect. Only the two `runtime.clientEvents` listeners check that a
frame payload is an object before reading its `type`; every other subscribing family took the
matrix's `result-absent` and `result-null` partitions as an uncaught TypeError, which failed the
suite rather than recording what a malformed frame does to a subscription. That is the same rule
the crash boundary already holds for a screen and the unhandled-rejection window for a detached
effect. The scenario's own faults stay loud: a missing subscribe payload, a params mismatch and a
closed stream are all raised outside the caught region.

`recorderSha256` therefore moves, so all 679 pre-existing goldens are re-recorded from the pin
with this branch's recorder laid over it. Every one of them moves exactly one line and that line
is `recorderSha256`: no `adapterSha256`, no `scenarioSha256` and no observation moved.

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

* refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6)

Five references over four files leave the raw request port. Each was held out of step 4 because a
request-only recorder could not mount it; the recordings landed in the previous commit and no
golden moves here.

- `use-live-worktree-name.ts` — `worktree.show` inside the focus effect that opens
  `runtime.clientEvents`. It reuses `sessionWorktreeRecordRead`, which is the diff-comment loader's
  reader renamed: both consumers read the `worktree` member whole and narrow their own field off
  it, so a second family would have been a second name for the same wire. The resolution still
  comes off the raw reply, because `selector_not_found` is what proves the worktree is gone and no
  acceptance policy carries a refusal code; the skip that follows is the same verdict main's
  `!response.ok` reached, since a refusal is the only reply this policy declines.
- `use-mobile-native-chat-session.ts` — `nativeChat.readSession` in the paging callback. The
  payload stays whole because the reply is a union: an older runtime answers `{ error }` in place
  of a window, and the caller discriminates before reading a message list.
- `mobile-notifications.ts` — `notifications.unsubscribe` in the `ready` branch of the
  subscription callback, in its own module rather than beside the push-route sends: one is the
  route this device holds with a gateway, the other the socket the paired connection holds.
- `use-mobile-session-terminal-input.ts` — the gesture flush reuses `terminalInputSend`, which
  already carried the four other terminal-input call sites and the same accepted-verdict, and the
  menu's clear gets `terminalBufferClear` beside it. The clear is a skip because main never read
  the envelope: it toasted success on any fulfilled reply, so only a transport rejection reached
  the failure toast. That is preserved, not repaired.

`mobile-session-route-parity.test.ts` refreshes three pins with their reason: the callback bodies
and the twelve nested-function bodies moved where those send expressions were rewritten, and the
runtime-string count drops by two because `terminal.send` and `terminal.clearBuffer` are now fixed
at their operation's definition instead of spelled at the call site.

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

* test(mobile): hold the subscription coverage as a checked inventory instead of a README paragraph

Every product `client.subscribe` is now an entry in
`mobile/src/transport/rpc-subscription-inventory.ts`, classified as recorded (naming its family),
an unwritten scenario, or walled with the wall named. `rpc-subscription-boundary.test.ts` fails on
a new site with no entry, an entry whose file no longer subscribes, an entry naming a method the
file does not open, and a `recorded` entry whose family the scenario manifest does not have. Both
the unlisted-site and unresolved-family gates were checked by removing an entry and by misspelling
a family; each fails on its own assertion.

The paragraph this replaces said nine sites when there were ten. It counted over `mobile/src`, and
the host screen's `accounts.subscribe` lives under `app/` — so the scan here covers both roots, the
way the raw-port ratchet next door does. Ten sites today: four recorded, two unwritten scenarios,
four walled (two on the webview ref, one on the multi-host client context, one on two unsubstituted
view members).

Unlike the raw-port inventory this list does not count down to zero. A typed operation fixes one
method, one acceptance and one reader for one reply; a stream has many, and replacing a subscribe
is not what this is asking for. The question it holds is the other one — which stream a golden
actually has, and for the rest, what exactly stops it.

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

* test(mobile): pin the notification stream close, which writes nothing to the wire

Deleting `unsubscribeStream()` from the notification cleanup — the local close,
not the `notifications.unsubscribe` RPC beside it — survived all 810 tests.
Neither unsubscribe builder in the stream registry knows `notifications.subscribe`,
so closing that stream sends no frame; the mutant leaks a live subscription record
instead, and the leak only surfaces when the logical client replays it onto the
next session. `notifications-desktop-stream-closed` stops the stream and then cuts
over, where the leak becomes a second `notifications.subscribe` payload.

Recorded at the pin. No existing golden moves: the new scenario is appended, so it
is not the family's matrix base, and every notification matrix site already had a
fulfilled reply to replay.

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

* fix(mobile): name the accounts screen's real wall, which is ScrollView and Alert

The entry blamed `expo-router.useFocusEffect`, which is substituted, and the
inventory's own `use-live-worktree-name` is recorded while importing it. Probed
by mounting the screen through the trap: the first refusal is
`Unsubstituted native member: react-native.ScrollView`, and `Alert` refuses too.

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

* refactor(mobile): drop the terminal-send response reader that lost its last caller

`isTerminalSendRpcAccepted` read the verdict off a whole envelope, which is what
the raw call site did. Both callers now send through an operation and read the
admitted payload, so the response form had only its own test left. The three
cases move onto `isTerminalSendResultAccepted`, with the refusal envelope's
missing result standing in for the failed response.

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

* test(mobile): attribute a frame crash to the listener that threw, not to the registry

The try wrapped `stream.deliver`, so anything the registry raised on its way to
the listener was recorded as a `stream-listener-crash` effect and blamed on the
product. A reply like `{ok:false}` with no error object throws reaching for
`error.message` before any listener runs, and that is a scenario that stopped
matching, not an observation.

Only the product's own `onData` is wrapped now. The throw is stashed and
rethrown unchanged, so the registry still sees it the way a device's message
handler does and what it skips after a dead listener stays recorded rather than
invented; `frame` reports it only when the error it caught is the one the
listener raised. `FrameListenerCrash` is local to the file again.

Engine change, so every golden re-records: 694 files, every changed line the
`recorderSha256` header, no body movement. Against main the set is 679 modified
header-only and the same 15 added.

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

* fix(mobile): read the frame listener stash through a method, not a narrowed field

`this.listenerCrash = null` before the try narrows the property to `null` for
the rest of `frame`, so the catch compared against `never` and mobile's own
`tsc --noEmit` failed. A private taker returns the declared type.

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

* fix(mobile): abort a registry throw that stashed nothing, and fold the last native-chat read module in

The frame catch compared `crashed?.error !== error`, which is false when nothing
was stashed and the registry threw `undefined`, so that abort was swallowed and
`frame` reported a clean delivery. It now asks whether a listener crashed at all.

Also: `nativeChatSessionPageRead` moves beside the three other `nativeChat.*`
reads and its one-export module goes; the session read header names the whole
`worktree.show` record rather than review notes; the guarded-listener count is
three, not two; the README names the ten subscribing sites blur is unrecorded
across; and the gesture flush reads the send verdict as `=== true` like the
other four sites.

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

* chore(mobile): drop an oxlint disable the rule never needed

`no-throw-literal` is not enabled here, so the directive read as unused and
failed the changed-code quality gate.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 01:59:55 -04:00
Jinjing 4948afc2ec fix(linear): show 'Cannot verify' when skill scan is inconclusive (#20964)
A scan that encounters an error before discovering skills, or hits an
unreadable root, cannot vouch for "not installed". Previously these cases
were conflated with proven absence, so the checklist would claim the skill
step incomplete even when all three steps had been finished. Now the UI
distinguishes between confirmed states and unknown ones, showing "Cannot
verify" instead of listing the skill as an unfinished step.
2026-09-15 22:53:48 -07:00
Jinjing feb04ec254 Virtualize automations run history table for large histories (#20916)
* refactor(automations): virtualize run history table

- Add virtual scrolling to AutomationRunHistory for efficient rendering of large run lists
- Implement sticky table header that stays visible during scroll
- Update keyboard navigation and focus management for virtualized rows
- Move AutomationRunsTable header inside scroll container for visual consistency
- Add virtualizer-test-stub for testing virtual scroll behavior without DOM measurement
- Cache DateTimeFormat to avoid per-cell allocation overhead

* test(automations): add coverage for virtualized run table

- Tests verify row content renders spend, tokens, and workspace labels correctly
- Keyboard navigation guards prevent operations during failed host reads
- Load-more pagination triggers at scroll end and respects page boundaries
- New fixtures support flexible automation run and usage test scenarios

* test(automations): verify scroll-to-focus path in virtualized runs

- Implement scrollToIndex in virtualizer stub to move viewport window
- Optimize row-size estimation to use predicate instead of labels
- Test validates keyboard navigation scrolls rows into view before focus

* add more tests
2026-09-15 22:21:38 -07:00
Jinjing b0d46e2d3d fix(settings): preserve multiline proxy bypass rules (#20957) 2026-09-15 22:08:40 -07:00
Jinjing bfc297df99 fix(settings): keep integration connect dialog drafts on backdrop click (#20932)
* fix(settings): keep integration connect dialog drafts on backdrop click

A backdrop click in the Settings → Integrations Jira/Linear/Bitbucket
connect dialogs dismissed the Radix modal, and each dialog's reset-on-open
then wiped the typed credential.

Generalize SshTargetForm's dirty-gated outside dismissal into a shared
preventOutsideDismissWhenDirty factory and wire it into the three dialogs
(and SshTargetForm), so an accidental backdrop click no longer discards a
draft while Escape / Cancel / × remain the explicit discard paths.

Bitbucket compares email/baseUrl against a props-seeded baseline and only
counts the active auth mode's fields, so a mid-edit status refresh and a
mode toggle cannot make the form sticky.

STA-7332

* test(e2e): drop ticket id from dismiss spec comment
2026-09-15 22:03:16 -07:00
Brennan Benson 9ed561c1d2 fix(claude): judge Stop against the turn the journal published (#20921)
* fix(claude): judge Stop against the turn the journal published

A Stop could be refused for the turn the user was actually looking at. The
client derives the id it sends from the published journal rows, but the host
compared it against the adapter's own in-memory turn. The journal sink drains
asynchronously, so that in-memory value can already name a turn whose row has
not landed — an id no client has been shown, and one the client's Stop can
therefore never match. The user pressed Stop and nothing stopped.

Fix the guard's source rather than the guard. ownsRequestedTurn stays: it is
what stops a delayed request from interrupting a later turn, and without it a
stale Stop would reach a session-scoped interrupt that settles every queued
send as durably rejected. The host now resolves the live turn from the journal
projection and hands it to the adapter, which prefers it and falls back to its
in-memory read for direct callers that have no journal.

It is passed as a read rather than a value because the guard re-checks after
the delivery fence may have waited seconds; a value captured at request time
would interrupt whatever turn ran next.

Both callers supply it. The handoff's own Stop bypasses performCancel, so its
body moves into stopNativeHandoffTurn beside the file's other extracted flows,
which is also what lets it be tested on its own.

* fix(claude): fall back to the in-memory turn while the journal drains

The journal drains through a serialized async queue, so a live turn routinely
has no published row yet. Judging a Stop only against the journal refused in
that window, which gates a user action on bookkeeping. The journal stays
authoritative while it HAS an answer; a null read falls through to the
in-memory turn, and the nothing-dispatched clause is unchanged.

Both call sites now read the live turn through `journal.activeTurnId()`, which
folds reduced items instead of rendering and sorting a whole snapshot.
2026-09-15 21:56:49 -07:00
Jinjing 47bb473ec6 Remove agent map from dashboard popout (#20929)
The agent map view was not functional and its components have been removed entirely. The dashboard popout now only supports the kanban board view, with all map-related code, utilities, types, and translations cleaned up accordingly.
2026-09-15 21:55:06 -07:00
Jinwoo Hong 96d77b37c5 perf: always show project names and remove notification scans (#20931)
* perf: avoid repeated agent scans when labeling notifications

* perf: always label notifications and remove project counting

* fix: qualify the notification project group by its folder's host

Folder notifications resolved the folder host-aware, then looked its
project group up by bare ID. The owner index fails a bare ID closed when
two hosts publish the same group ID, so a remote folder lost the project
name the catalog already had.

Also drops the identity rescans that recovered display fields: the
catalog finders now return the caller's row type, matching
findIndexedRepoOwnerForHost.

Updates the idle-arbitration expectation that still asserted the removed
hasMultipleActiveRepos flag.
2026-09-16 00:46:46 -04:00
Brennan Benson 357c9780f8 refactor(agent-launch): retire the duplicate worker-start mode decision (#20911)
`orchestration-worker-start-mode` becomes a thin adapter over
`agent-launch/agent-launch-mode`, which already owns the same decision.
Orchestration keeps its receipt vocabulary via WORKER_START_VOCABULARY, so
every sentence a dispatch receipt prints is unchanged.

Recovers the cutover written in f34d08a452, which a later merge resolved in
favour of main's side; the two added files survived, the deletion half did not.
2026-09-15 21:31:17 -07:00
e6a41081a3 fix: name the id-kind mismatch when --ack is given a message id (#15743)
* fix: name the id-kind mismatch when --ack is given a message id

`orchestration check --ack` takes the batch's delivery id, which the check
response returns as the top-level `deliveryId`. Passing a message id
instead produced:

    stale_delivery: Delivery msg_5d5cdf77614c does not belong to this Run.

That states a Run-scope verdict for what is really the wrong kind of
identifier, and it names no field that carries the right one. The only id
visible while reading the message list is the message `id`, so the message
sends the caller hunting the wrong axis — #15697 is a detailed report that
concluded the ack path was broken and no delivery id was exposed, when both
were fine.

When the value misses the deliveries table but hits the messages table, say
so and point at `deliveryId`. Anything else keeps the original wording,
including a delivery that exists but belongs to another Run — that one
really is a scope verdict.

Refs #15697

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

* test: bind message-id diagnostics to queued rows

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-15 21:00:27 -07:00
NeilandMumuTW 7f23d4463d refactor(daemon): consolidate snapshot-safe listener delivery (#20945)
Consolidate snapshot-safe daemon PTY listener delivery and exit payload construction. Reuse listener removal and event types while preserving callback ordering, payload isolation, optional exit fields, and recovery exception handling.

Fixes #10984. Adapted from #11119.

Co-authored-by: MumuTW <42820974+MumuTW@users.noreply.github.com>
2026-09-15 20:50:37 -07:00
NeilandJuuuuHong 07c7606fee fix(computer-use): reap detached macOS helpers through owner exit (#20926)
Reclaim detached macOS computer-use helpers on abandoned requests and transports. Keep ownership from spawn, escalate SIGTERM to SIGKILL, force pending reaps when the sidecar exits, and clean each failed startup's private socket directory.

Based on #14494 by @JuuuuHong. Preserve the original helper ownership/reaping design and regression tests while retaining the upstream line-buffer optimization and adding real-process teardown and resource-bound tests.

Fixes #9141.

Co-authored-by: JuuuuHong <juhang720@gmail.com>
2026-09-15 20:18:21 -07:00
Jinwoo Hong c33a446190 feat(mobile): clarify the notification opt-in screen (#20930)
* feat(mobile): clarify the notification opt-in screen

Replace the generic enable-notifications prompt with copy and a looping
banner preview that show background alerts when an agent finishes or is
waiting, even if the app is closed.

* fix(mobile): share reduced-motion hook and wait before animating

Extract the duplicated onboarding reduced-motion probe and hold the
banner loop until the OS preference is known, so Reduce Motion users
do not see the first cycle.
2026-09-15 22:47:24 -04:00
Jinwoo Hong d4c19d5db4 test(mobile): let the RPC recorder open a subscription and script its frames (step 6 capability) (#20920)
* test(mobile): let the RPC recorder open a subscription and script its frames

The request-only runner threw on `client.subscribe`, which is why seven raw-port
holdouts read "the recording runner refuses to open one". It no longer does.

`ScriptedRpcTransport` drops the real `RpcClientStreamRegistry` into each physical
session, the way it already reuses `RpcClientRequestTracker` for requests, so
subscribe params, frame routing and the unsubscribe wire all come from product
code. Per session, not shared: a frame is routed by the session that published
its subscribe, and after a cutover the retiring registry is what holds a
cancelled subscribe long enough to unsubscribe it once its id arrives.

A subscribe writes to `payloads` through the same hook a request does, named by
per-method occurrence, and frame ids come from the transport's existing counter
because the real `DirectRpcClient` shares one counter across requests and streams.

New scenario step kind `frame`: it names a subscribe payload, asserts its params
the way `complete` does, and hands a whole host response to the real
`handleResponse`, so `ready`, a data event, the host's end-of-stream pair and a
refusal are one step kind rather than four.

Every `payloads` entry now carries `sent`, the request count at write time, the
same stamp `effects` already use. Without it, swapping `client.subscribe` and the
first `sendRequest` in a product source moves zero bytes: a subscribe publishes
synchronously while a request waits for connected, so the payload order is
identical either way and only `sent` moves.

The reply matrix now drives frames as sites, named by payload and occurrence
because one subscribe carries many frames. Nine of the eleven partitions apply;
the two transport rejections are what a request promise fails with and a
subscription holds none. Success shapes keep the scripted frame's `streaming`
flag, which is what routes a response to the open stream.

`useFocusEffect` is substituted as `useEffect`, so a route's focus cleanup is
recorded at unmount and a blur-triggered unsubscribe stays unrecorded; the README
says so rather than a driven focus substitute no recording reads.

Four tests, each killing a named mutation: routing a frame through the current
session instead of the publisher, delivering a frame to the request tracker,
dropping the `sent` stamp, and reading only `'complete' in step`.

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

* test(mobile): record the two runtime client-event stream consumers

Two families, both driven through the new frame step, as the capability proof for
the subscription recorder.

`session.live-worktree-name` mounts `use-live-worktree-name.ts` end to end:
subscribe, `worktree.show`, a `ready` frame, the fulfilled name, a
`worktreesChanged` frame, the follow-up `worktree.show`, then unmount and the
`runtime.clientEvents.unsubscribe` its focus cleanup sends.

`worktree.host-refresh` mounts `startHostWorktreeRefresh`, whose whole output is
when it calls the two fetches it is handed. It sends no request of its own, so it
is also the family that would have thrown `No scripted reply to drive a matrix
over` before a frame was a matrix site. The 3 s foreground poll is driven by an
`advance` step, which puts `WORKTREE_REFRESH_MS` under recorded time.

Both adapters live in one new module, registered like every other domain, so the
two families' goldens are pinned to a file that holds only them.

No product source changes and no call site migrated: the seven raw-port holdouts
and the `client.subscribe` zero-reference assertion belong to the migration PRs.

`accounts.subscribe` in `use-mobile-home-host-connections.ts` is left out. Its
snapshot decoder is re-exported through a React Native screen module the loader
cannot reach, which is the same wall the accounts read has always been behind, so
it needs a substitute beyond what these two read.

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

* test(mobile): re-record every golden for the subscription recorder

Engine files changed, so `recorderSha256` moves and every header re-digests, and
`payloads` entries carry a new `sent` key. Nothing recorded moved.

Recorded from a detached worktree at the pinned baseline with this branch's
recorder laid over it, per the README's awkward case; `baseline` is unchanged.
Decoding both sides through the value pool and ignoring `recorderSha256` and the
new `sent` key: 641 compared, 6 header-only (the six goldens with no payload at
all), 635 sent-only, 0 other, 9 added, 0 deleted.

The 9 added are the two new families: a pilot golden each, four reply-matrix sites
for the live title (two requests and two frames) and three for the host refresher
(three frames, and no request of its own).

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

* test(mobile): take the broad object parameter out of the frame partitions

`audit:anti-slop`'s no-object-parameters rule fires on a parameter typed `object`,
which the frame-partition helper took to spread a success envelope. One function
narrowing `unknown` to a spreadable envelope replaces the two that split the
check, and the streaming flag is now read as `=== true` rather than by key
presence, matching `isStreamingOpenerReply`.

An engine edit moves `recorderSha256`, so every golden re-digests again. Decoded
through the value pool, all 650 differ on that header alone and on nothing else.

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

* docs(mobile): refresh the recorder's own scenario and golden counts

The paragraph still claimed 78 scenarios and 153 goldens over 210 tests, which
went stale across the domain additions since. It is 330 scenarios, 650 goldens and
757 tests as of this branch. The figures quoted further down are measurements of
the change each one describes, so they stay as written; a line now says so.

Prose is excluded from `recorderSha256`, so this moves no golden.

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

* test(mobile): take the inert optional off a frame, and pin the replay re-read

Review of #20920 found four things the first pass got wrong.

The `optional` flag on a frame step never gated anything: the registry routes every streaming
response to the id that opened the stream, retired or not, so `frame()` only ever throws for a
non-streaming reply. Dropping the parameter, the step field and the downstream marking moves the
scenario digest of two matrix goldens and no recorded byte.

The session comment claimed a mechanism that is not there. The re-send after a cutover comes from
the logical client's own subscription replay, not from the registry being per-session; a shared
registry is byte-identical. What being per-session buys is a frame routed through the session that
published its subscribe, which is what `DirectRpcClient` does too.

The host-refresh scenario now cuts over and answers a second `ready`, so the reconnect replay
branch is recorded: deleting its re-read moves this family. Before, that branch was source no
golden reached.

README over-claimed the subscribe port as covered. Nine product call sites subscribe, two are
recorded, and the other seven are now named with what stops each.

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

* test(mobile): re-record for the frame flag removal and the replay cutover

644 goldens move on `recorderSha256` alone, from the engine edit. Two more also move
`scenarioSha256`: the live-worktree-name matrix variants that used to carry `optional: true` on a
downstream frame. Four bodies move, all in `host-worktree-refresh` — the pilot and its three matrix
goldens now record the cutover, the re-subscribe payload, the retiring unsubscribe and the extra
worktree/repo read the replay branch does. One golden is added, for the matrix site the second
subscribe payload opens.

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

* docs(mobile): count the golden the second subscribe payload adds

Prose only; moves no golden.

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

* test(mobile): pin the live-worktree-name replay re-read too

The same cutover treatment as host-refresh: the scenario now migrates the logical client, answers a
second `ready` on the re-sent subscribe, and answers the title read the replay branch makes. Before
this, deleting that re-read from `use-live-worktree-name.ts` moved no golden.

No engine file changes, so `recorderSha256` holds and 646 goldens are byte-identical. Five bodies
move with their scenario digest, all in this family, and two matrix goldens are added for the sites
the second subscribe payload and the third title read open.

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

* docs(mobile): say what a request count cannot order, and name the accounts wall

`sent` counts requests, so it orders payloads and effects against sends and not against each other.
A family that sends none has no ordering at all: `host-worktree-refresh` keeps `sent` at 0 through
every checkpoint, and moving its two initial reads across the subscribe moves no golden. The fix is
one write ordinal shared by all three lists, which forces a full refresh.

The `accounts.subscribe` wall was misdiagnosed. The loader reaches `decodeAccountsSnapshot` and it
throws its own domain error; what the runner cannot supply is the multi-host client context
`useAllHostClients` reads.

Also honest about the record recipe: where a branch must not repin `baseline`, the detached-pin
worktree is the only one that runs, merged main or not.

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

* test(mobile): file only a subscribe as an open stream, and drop three unused seams

The registry sends its unsubscribes through the same `sendEncrypted` hook as its subscribes, and the
hook filed every payload under `openStreams`. A frame aimed at an unsubscribe name therefore routed
at that wire id, matched no stream, recorded nothing and reported success — where the README promises
`Missing subscription payload`. A latch around the session's `subscribe` wrapper files only what a
subscribe published. Its test fails without the latch.

Three seams no caller varies, the same shape as the `optional` flag: `frameReplyPartitions` took a
`scripted` reply to copy `streaming` from, but every frame site scripts a streaming reply, so the
flag is stamped and a non-streaming unary closer as a base frame is called unsupported; the
divergence map's three-deep ternary is early returns, since `index > divergence` already implies
`index !== divergence`; and `MatrixSite` is no longer exported.

Body-inert: re-recording into a scratch dir at this tree moves all 653 goldens on `recorderSha256`
and nothing else, decoded through the value pool. The goldens are left stale for the merge re-record.

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

* test(mobile): re-record every golden after the main merge

One record at the pin, with this branch's recorder, scenarios and driver script overlaid on a fresh
detached worktree. Decoded through the value pool against `origin/main`: 667 shared goldens, 6
header-only on `recorderSha256`, 661 also gaining the `sent` stamp this branch puts on every payload
entry, nothing else moved, and 12 added — the two client-event families and their matrices. No
`adapterSha256` moved, so main's adapter work was already recorded against its own goldens.

Those 12 are byte-identical to their pre-merge bodies, `recorderSha256` aside, so the merge changed
nothing this branch recorded.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 22:42:58 -04:00
Jinwoo Hong b8d4cde09f refactor(mobile): send six screen-mounted call sites through typed RpcOperations (step 4, wave 3) (#20919)
* test(mobile): record six screen-mounted call sites before migrating them

Five new mount adapters and six scenarios, recorded against the pinned baseline's
product code so the goldens are main's behaviour, not the refactor's.

Each site is a screen the recorder could not previously mount:

- `home.host-accounts` mounts `fetchMobileHomeAccounts`, whose decoder is
  re-exported through `AccountUsage.tsx`. That module loads under the mount
  loader, so the inventory's "no recording can load it" was already stale.
- `notifications.display-test-screen` mounts the settings push probe and presses
  its button by reading the handler back off the rendered inert `Pressable`.
- `aiVault.history-screen` mounts the history panel, which is where the last
  `worktree.ps` lives. Split in two: the base stops once the worktree list has
  seeded the scopes, because a reply partition there changes the scopePaths the
  downstream `aiVault.listSessions` carries, and a matrix variant cannot assert
  params it moved. The full chain is a second scenario, driven as a pilot only.
- `tasks.route-repo-list` mounts the tasks screen-root hook and calls its own
  `ensureLoaded`, which is the only thing that fires `repo.list`.
- `linear.select-workspace-picker` calls the render helper the tasks surface
  calls and invokes the `onSelect` on the element it returns. The picker draws
  inside `BottomDrawer`, whose reanimated timing driver and gesture builder the
  recorder would have to impersonate for a row to exist; the closure is the same
  either way, and the workspace a selection carries comes from the scenario.

Five substitute members are added, each with the recording that reads it:
`react-native-safe-area-context.useSafeAreaInsets` and
`expo-router.useLocalSearchParams` for `tasks.route-repo-list`, and
`react-native.TextInput`, `.SectionList` and `.RefreshControl` for
`aiVault.history-screen` once its list renders. `useLocalSearchParams` answers one
pinned route for the same reason the window size is pinned: a screen's own address
is not a device reading, and the one screen that reads it sends `repo.list`, which
takes no params.

Touching the substitute table moves `recorderSha256`, so all 641 existing goldens
are re-recorded. Recorded from a detached worktree at the pinned baseline with this
branch's recorder laid over it: every pre-existing golden is header-only, verified
by resolving both sides through the value pool — 641 header-only, 0 body, 0 deleted,
one distinct `recorderSha256`, `baseline` and `lockfileSha256` across all of them.

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

* test(mobile): type the linear workspace picker's model fixture

`mobile/tsconfig.json` covers the recorder, and the fixture's setters were written
with the argument the product happens to pass rather than the `SetStateAction` the
model declares. Typing them moves `adapterSha256` on the two goldens recorded through
this module, so they are re-recorded here rather than in the refactor commit, which
must move none.

Re-recorded at the pinned baseline: `linear-select-workspace` and its reply matrix,
header-only, bodies unchanged.

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

* refactor(mobile): send six screen-mounted call sites through typed RpcOperations

Nine references off the raw request port, across six files. Every one is proven
against the goldens recorded in the previous commit from the pinned baseline's
product code: this commit moves no file under mobile/rpc-foundation/goldens.

Reused rather than redefined:

- `worktree.ps` in the history panel sends through `worktreeCatalogRead`. Same
  question, same acceptance — a refused list leaves the screen on what it holds.
- `repo.list` in the tasks screen-root hook sends through `newTabRepoListRead`.
  Its policy raises the host's message and its reader takes `repos` off the payload
  while preserving the property-read exception a null result used to throw at the
  cast, which is what this call site did by hand. Its name still says new-tab; a
  third consumer does not make renaming it this bucket's business.

Four operations are new, each because no existing reader on the method takes this
consumer's input:

- `files.read-directory-or-skip` and `files.legacy-explorer-list-or-skip` for the
  explorer. Both skip, because neither refusal is the operation's to decide: the
  readDir refusal code selects the legacy fallback and the list refusal supplies the
  message. The existing `files.list-or-skip` reads the `files` member alone, and the
  explorer also needs `truncated` for the "Showing first 5000" note.
- `accounts.home-snapshot-or-skip` for the Home card, decoded by
  `decodeAccountsSnapshot` at the call site as before.
- `notifications.test-push-or-skip` for the settings probe, whose `forbidden` and
  `method_not_found` refusals mean "try the next desktop".
- `linear.select-workspace-or-skip` for the filter sheet.

Two behaviours are preserved rather than repaired, both recorded:

- The workspace switch never read its reply. `.then(() => loadLinearContext())` runs
  on a refusal exactly as on a success, so only a transport rejection reaches the
  error copy. Interpreting the operation here would surface a refused switch for the
  first time; that is a product change with its own re-record.
- `app/terminal-settings.tsx` still reads `ms` off the reply envelope instead of off
  its result, so the value is always undefined. It did not migrate, and the inventory
  now carries the defect as its own note.

Four mutants are added, one per new family that admits a state-only one:
the Home snapshot, the push test result and the tasks repo list each decoded one
level above the envelope, and the workspace switch with its context reload dropped.
`aiVault.history-screen` gets none and says why in the suite: everything
`worktree.ps` publishes also moves the `scopePaths` the next scripted completion
asserts, so a mutant aborts the sequence instead of diverging from it. Its evidence
is the reply matrix at that request.

The tasks source-parity ratchet moves with the family it guards: hook, statement,
declaration, render and style counts are unchanged, and the semantic source is a pure
deletion of four lines — two `rpc:` call signatures and the two method literals they
carried.

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

* test(mobile): matrix the six new screen families' replies

One golden per scripted reply, eleven partitions each, recorded at the pinned
baseline alongside the pilots. Seven sites: `accounts.list`, `notifications.testPush`,
`repo.list`, `linear.selectWorkspace`, and all three of the history screen's —
`worktree.ps` and the two `status.get` reads its scan chains off the worktree list.

The history matrix is also that family's defect evidence in place of a mutant: every
partition at `worktree.ps` changes the `scopePaths` the downstream `aiVault.listSessions`
carries, and the sender args are recorded with it.

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

* docs(mobile): correct three operation and mutant comments

Comment-only, no product behaviour and no golden movement.

- `worktreeCatalogRead` says two readers; there are three. Names the third
  (the agent-history panel's `scopePaths` seed) and drops the stale count from
  the module header, which described call sites rather than the two operations.
- `newTabRepoListRead`'s census counted the two operations over `repo.list`, not
  its own two callers, and claimed both read a workspace's connection id. The
  tasks route keeps the whole list for its repo pickers. The split from
  `nativeChatRepoListRead` stays where it belongs: acceptance.
- The `aiVault.history-screen` mutant note pointed at the reply matrix as the
  accepted-vs-refused oracle. Decoding
  `matrix-aivault.history-screen-worktree.ps-1.json` through the value pool
  shows `normal`'s projected state is identical to all seven non-crashing
  partitions (spinner, two labels, zero rows). The real oracles are the next
  request's `scopePaths` (`["/repo/feature"]` vs `[]`) and the crash channel the
  three `inner-*` partitions land in.

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

* docs(mobile): give the second files.list reader its real reason

Comment-only, no product behaviour and no golden movement.

`legacyFileListRead` claimed "the member reader rejects this consumer's
input". Nothing rejects: `rpcUncheckedMemberReader` returns the member,
and reusing it here would simply drop `truncated`. The reason the explorer
declares its own operation is the other direction. Widening
`files.list-or-skip` to a payload reader would split the `workspace-files`
variant it shares with `nativeChatFileSearchRead` over
`files.searchPaths`, whose only caller feeds both through one
`extractPaths` in `use-mobile-native-chat-file-search.ts`, so the member
read would move into that hook rather than disappear.

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

* style(mobile): indent the six scenario entries spliced during the merge

The conflict on `pilot-scenarios.json` was resolved by id rather than by
hunk, splicing this branch's six entries into main's text at the array's
close. The splice started at the entry's `{` instead of at its line, so
those six lines lost their indentation. oxfmt's only change is those six
lines; the parsed document is identical, and the recording suite still
matches all 667 goldens, so no scenario digest depends on the raw text.

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

* test(mobile): re-record the merged goldens once at the pin

One record for the whole merged tree, at the unchanged baseline
e7206f62a8, through a detached worktree reset
to that pin with this branch's rpc-recording tree, scenarios and recorder
script overlaid. Product source in that worktree was proven identical to the
baseline before the run, so the recordings describe the pre-refactor product.

13 goldens move, all of them the ones #20915 added. They arrived carrying the
recorder digest from before this branch edited `screen-native-substitutes.ts`,
and `recorderSha256` is the only key that moves on any of them; every
recording body is identical after decoding through the value pool. The other
654 were re-recorded byte-for-byte and are not in this commit.

All 667 goldens now carry one `recorderSha256`, one `baseline` and one
`lockfileSha256`.

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

* docs(mobile): state the real gates on two screen holdouts

Comment-only, no product behaviour and no golden movement.

The accounts route said "the screen now mounts". It does not, at this
commit: it reads `expo-router.useFocusEffect` and `react-native.ScrollView`,
neither is a substituted member, and the trap refuses before any effect
runs. The note now names that as the first gate and the `accounts.subscribe`
effect as the second, and says why the two members are not added here.

The host-screen overlay note blamed a "reanimated timing driver" for
deciding when the drawer's children exist. Nothing gates them:
`resolveBottomDrawerMounted` returns `visible || mounted`, `BottomDrawer`
renders `MountedBottomDrawer` on that, and that component renders its
children unconditionally inside its `Modal`. The blocker is the module's
own imports of reanimated and gesture-handler, neither substituted.

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

* test(mobile): drop the tasks route adapter's unreachable reload action

No scenario names `reload-repos`, and no schedule driver can generate it:
the drivers emit only disconnect, cutover, reset, unmount, blur and remount.
Every other action on this adapter is reached by a scenario. Deleting the
branch leaves the remount and unmount branches, which are driven.

Re-recorded once at the pin e7206f62a8 with
the product source in that worktree proven identical to the baseline first.
Two goldens move, both in the `tasks.route-repo-list` family, with
`adapterSha256` the only moved key and both recording bodies identical after
decoding through the value pool. The other 665 re-recorded byte-for-byte.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 22:10:22 -04:00
Jinwoo Hong 615b1370fb refactor(mobile): own the request/cache lifecycle in GenerationScopedRequestOwner, piloted on the legacy file inventory (step 5) (#20914)
* feat(mobile): own the request/cache lifecycle in GenerationScopedRequestOwner (step 5)

Hooks guard stale replies with hand-rolled generation counters, `isCurrent`
callbacks and latest-wins refs, so the guard is a callback a caller may forget.

The owner keeps the cache, the in-flight identity and the generation token
private. `read` and `load` are handed the scope and build the key themselves, so
a scope the owner has not seen retires everything it held before it answers, and
two workspaces cannot share a key. Publication goes only through
`commit(lease, value)`: the lease brand is module-private, so no caller can mint
one, and a lease whose generation moved is refused. `reset` bumps even when the
scope came back to where it started, as in A to B to A.

Three epochs may sit in a scope and they are not the same thing: the logical
authority epoch, the physical authenticated session and the negotiated
capability epoch. Which of them retires a given owner's data is that owner's
decision, expressed by what its callers put in the scope.

`lifecycle-owner.test.ts` carries one named schedule each for
key-reset-cleanup, blur, cutover, reconnect-mid-request and
stale-inflight-cleanup, each written as an explicit resolution order. It also
fences loader bodies: a `load` callback that writes state it did not declare is
rejected by the same kind of source scan that fences raw casts. Compile-time
assertions live in a non-test file because mobile's tsconfig excludes tests.

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

* refactor(mobile): put the legacy file inventory on the lifecycle owner (step 5)

The native-chat file search kept three hand-rolled guards for one request: a
generation counter bumped by an effect, a committed-paths ref, and an in-flight
ref whose `finally` cleared itself conditionally. The stale-reply check lived in
the reply handler, where a caller could forget it.

The owner replaces all three. `read` and `load` are handed the scope, so the
guard runs before either can answer, and the reply is published only through
`commit(lease, value)`. What retires the inventory is named at the call site:
this host, this workspace, this logical authority epoch. A reconnect to the same
host leaves the files on disk alone, so the physical authenticated-session epoch
is deliberately not in the scope.

`RpcClient` gains one optional read-only signal, `getGeneration`, so a holder of
a bare client can scope cached work to the logical authority epoch that
`StableLogicalRpcClient.migrateTo` advances. Nothing else about either client
widens.

No golden moves: all nine legacy-inventory recordings reproduce byte for byte,
including the A-to-B-to-A and cutover schedules. The `race` mutant is re-anchored
on the owner's generation compare, which is now the only place that compare
exists, and it still dies against b1.

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

* refactor(mobile): cut the lifecycle owner down to what callers use (step 5 review)

Review round 1 on #20914 found three pieces of surface with no product
reader and one vacuous assertion.

`dispose()` is gone with the `disposed` field, the three guards that read
it and the `'disposed'` verdict arm. A React effect cleanup cannot use it:
the pilot's cleanup runs on every dep change and the owner outlives it in
a ref, so a workspace select would dispose it permanently. Swapping
`reset()` for `dispose()` there fails 7 tests across 3 files.

`capacity` and its eviction loop are gone too. No caller varied it, so the
loop never ran in production, its `if (oldest.done) break` was
unreachable, and it evicted in insertion order while its name said
capacity.

`RequestCommitVerdict` and `RequestParameters` lose their `export` (no
importer), as does the `generation` getter and the expect-error assertion
that pinned it (test-only reader; `reset` advancing is proven by the
verdict a lease from the previous generation gets). `LoadedRequest` keeps
its export: it names the value of the public `load` promise, which a
helper over that result has to write down.

`key-reset-cleanup` now leaves a second request pending across the
`reset()` and asserts the post-reset load starts its own, which is the
half `inFlight.clear()` actually owns. Proof: deleting that line from
`retire()` failed this schedule and `stale-inflight-cleanup`; before the
change it failed only the latter.

`read`'s doc now says it retires an unseen scope before answering and
must not be called from render.

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

* refactor(mobile): read getGeneration off RpcClient and scope one attempt once (step 5 review)

Two call-site findings from review round 1 on #20914.

`mobile-session-tabs-stream-health.ts` hand-rolled
`RpcClient & { getGeneration?: () => number }` and cast through it with no
SAFETY rationale. `RpcClient` declares the member now, so both go and the
read is `this.options.client.getGeneration?.() ?? 0`.

The file-search pilot built its scope from a function it called twice in
one attempt, so a `migrateTo` landing between the cache read and the load
would have put one attempt in two scopes. It is a `const` computed once
per attempt.

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

* refactor(mobile): type the scope, drop the in-flight wrapper (step 5 review)

Review round 2 on #20914, owner side.

`RequestScope`'s element type now excludes symbol and bigint, so both are
compile errors with an assertion each in the fence. The runtime symbol
throw is gone with the untested branch it guarded, and the bigint case it
never covered (it reached `JSON.stringify` and threw V8's serialize
message from two frames down) cannot be written.

`InFlightRequest<Value>` existed only so its own `then` callbacks could
name the entry they belonged to, which forced a throwaway
`Promise.resolve(null)` that the next statement overwrote. The map holds
the request promise itself and `settle` compares promise identity.

`scopeMember` is inlined into `scopeKey`'s map callback: with symbol gone
the member type is the scope's element type, which spells `object`, and
anti-slop bans that in a parameter position. Inferred in a callback it is
the same type with no annotation to ban.

Header: `committed` says the generation still holds, not that the value
already in the caller's hand is fresh. The pilot displays `loaded.value`
directly and is fenced by the sequence counter it had on main.

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

* test(mobile): gate the epoch in the pilot scope and the in-flight slot identity (step 5 review)

Review round 2 on #20914 found two invariants no test held.

The pilot's scope: replacing `client.getGeneration?.() ?? 0` with `0` left
all 711 tests green. The new schedule pairs a control with the claim. A
second query under the same epoch is answered from the inventory already
held, and a query after the epoch advances issues a second `files.list`
and displays what the new authority's host returned. Same client object,
same workspace, so the epoch is the only thing that can retire it. Proof:
with the literal `0`, `files.list` count is 1 where 2 is asserted.

`settle`'s identity guard: making the delete unconditional left all ten
schedules green. `stale-settlement-cleanup` puts a request in flight,
resets, starts a live request on the same key, then settles the retired
one last, whose cleanup names the slot the live request now holds. A third
load must join rather than start. Proof: unconditional delete gives
`started` 3 against 2.

The fake clients go through one `fakeClient` helper, which is what lets
the new case name the two members the hook reaches without a fifth
`as unknown as RpcClient` (four deleted, one fenced assertion left with
its rationale).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 21:31:05 -04:00
Jinwoo Hong ea7902cbee refactor(mobile): send the device-state holdouts through typed RpcOperations (step 4, wave 3) (#20915)
* test(mobile): record the terminal input surface before migrating it (step 4)

Three families the recorder could not reach before, recorded against the pinned
baseline's product code so the migration that follows has a parity oracle.

The device state these hooks read is real, not declared. The pasteboard is the
engine's existing per-recording fixture, so a paste reads the bytes a recorded
copy put there one action earlier; the buffered draft store is the product's own
useBufferedTerminalDrafts mounted in the same tree. No engine file is touched, so
no existing golden moves and no header re-digests: 13 new goldens, 641 unchanged.

Only the clipboard's text path is driven. The image path decodes a raster through
expo-image-manipulator and stages it on expo-file-system, and recording it would
mean inventing image and file-system behaviour. Both paths reach the same send.

Two family mutants, one per family whose state() can observe a reply: keeping a
refused send's draft cleared, and resolving the first repo's connection instead
of the workspace's own. The paste family gets none — the hook returns void and
calls onSuccess for an accepted and a refused send alike, so its only
reply-dependent behaviour is the takeover report, which lives in the sender list.

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

* refactor(mobile): send the device-state holdouts through typed RpcOperations (step 4)

Ten references over six files, the last of the raw-port sites whose blocker was
that a recording could not reach them. Zero goldens move: every one of the six
was recorded first, and the suite replays them against the rewritten code.

Two operations are new and four sites reuse one that already fixes their method:

- accounts.consumeCodexResetCredit, throw-message, payload unread — the call
  site's decodeResetResult is one scope-and-snapshot check and splitting it
  across a reader would put one refusal rule in two places.
- notifications.getMissedSince, skip — a background pass with no screen to raise
  a host message on. The member read stays where the optional chaining was.
- repo.list: the accessory's connection lookup joins the new-tab reader, which
  already threw the host's message; the new-workspace dialog joins the skip
  reader, which already left the list it had. Same reader, same policies, no new
  acceptance rule and no third operation on that method.
- terminal.send: the composed send, the live keystroke send and the clipboard
  paste all join terminal.input-send, which the accessory raw send already used
  and which reads acceptance the same way isTerminalSendRpcAccepted did.

The typed contract is stricter than the client's own scope type on the redeem:
the catalog pairs each runtime with the distro it may name, while the shared
CodexResetCreditExpectedScope does not. The invariant is real and held by the
attempt journal's schema, so the narrowing is asserted at the send with that
named; the bytes are unchanged. Widening the catalog would be a wire change.

Two source-shape ratchets pinned the old call text and move with it. The route
parity suite's runtime strings drop from 540 to 537: the three method literals
that became operation definitions, and nothing else. Every hook, callback
identity, effect, JSX and style pin is unchanged.

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

* docs(mobile): correct the terminalInputSend and PTY-mode holdout comments

`terminalInputSend`'s doc still claimed two call sites. It now has five
non-test consumers, all on the same acceptance: the query-reply responder,
the live accessory raw send, the session screen's composed draft send and
live keystroke send, and the clipboard paste. That comment is where the
next person narrowing `object-result-or-null` learns whose lost-ack
meaning they are changing, so it names all five and their files.

The session inventory block closed with "opens or rides a subscription, or
takes its method as a parameter", which no longer covers every holdout
below it: `use-mobile-session-terminal-input.ts` is held out for a webview
handle. Its own reason also said PTY mode was unavailable in the runner,
which this branch's terminal-input adapter contradicts by fixturing the
mode map a paste reads. The sentence is narrowed and the holdout restated:
PTY mode is recordable, the live webview handle is what is left.

Comments only. No product behaviour, no golden, no parity hash moves.

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

* test(mobile): pair the draft-restore mutant with the refused send

`terminal-send-refusal-restores-draft` documents the harm of a refused send
that leaves the composed draft cleared, but it was driven by the accepted
scenario, where the kill comes from the inverse (a draft restored after a
send that landed). The refused scenario shows the documented harm directly:
without the restore the input stays empty after the runtime says no.

Still one mutant per family, and it kills there — verified by running the
suite, `terminal-input-send-refused: kills terminal-send-refusal-restores-
draft`. No golden, no product change.

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

* docs(mobile): correct the gesture-input holdout and drop a dead repo type

Three round-2 corrections, comments and one dead type; no behaviour.

The gesture-input holdout claimed a recorder gap that does not exist. The
flush path reads refs only — client, connection state, PTY modes, the
gesture buckets, active handle and tab type — and the clear-buffer
reference optional-chains the webview ref, so a mount with a null terminal
ref puts both sends on the wire. The reason now says what is true: those 2
references are migratable as they stand and were out of this PR's bucket.
The session summary sentence no longer offers a webview reason.

`RuntimeRepoSummary` in mobile-session-route-types.ts lost its last
consumer when the accessory hook moved to `MobileRuntimeRepoSummary`;
`git grep RuntimeRepoSummary` now finds only the `Mobile`-prefixed type.
Deleted.

Both refreshed route-parity hashes still credited the
`interpretOrThrowRefusalMessage` refresh for their current value. They now
state the invariant they pin and this PR's reason for the move: the sends
and repo reads inside those bodies name their `RpcOperation` instead of the
raw `sendRequest` port.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 21:12:33 -04:00
Brennan Benson 7ec2986fd1 fix(lint): merge the duplicate agent-status contract type imports (#20907)
main's tip fails audit:code-quality:native on import(no-duplicates), which
reds the static analysis and verify jobs of every open PR via the merge ref.
2026-09-15 17:36:08 -07:00
BAEK'spaceandJinjing 3520e8eb41 fix: highlight bash fences in Markdown source mode (#20592)
* fix: highlight bash fences in Markdown source mode

* refactor: trim shell fence alias registration

Drop the speculative exports and document the alias-resolution rationale in
one WHY comment; the idempotency guard stays.

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-09-15 17:32:54 -07:00
Brennan Benson a9232e8db6 fix(claude): single-own turn identity so Stop reaches a provider-opened turn (#20794)
* fix(claude): single-own turn identity so Stop reaches a provider-opened turn

Stop silently failed on any Claude turn the provider opened on its own — a
background task reporting in wakes the agent — once the session had dispatched
at least once. The transcript read "The provider had already finished this
turn." while the model kept working.

Turn identity was minted twice from the same stream by two components that
never talked. The journal translator writes turnId into the durable turn row,
which is the id every client's Stop carries. settleWaiter separately wrote
session.activeTurnId, only ever on the dispatch-echo path, and nothing cleared
it. Cancel read the adapter's copy; prompt binding, status and both clients
read the journal's. They agreed only when a send echo opened the turn.

Turn identity is now single-owned. The open turn moves out of the translator's
closure into ClaudeOpenTurn, which holds the turn and publishes its lifecycle
row, so the id readers ask for is the id the row carries. activeTurnId and
activeTurnSequence are deleted rather than widened, so the second writer goes
with them instead of a second guard being added beside the first.

activeTurnSequence was never turn identity: it asked whether a send was still
awaiting its echo, which an interrupt would release as an unexpected turn. That
is now derived from the live dispatch waiters. Deriving it also retires a latch
— a retired waiter left the stored sequence permanently behind the dispatch
sequence, refusing every later Stop for the life of the session.

Also fixes the mirror defect the same hazard caused: a stale turn id was
accepted against a newer provider-opened turn, because activeTurnId was never
cleared when a turn ended.

The Claude adapter fixture now acquires with a journal sink, as production
does; without one it modelled a session that never ships.

* fix(claude): reject stale stop after turn settles

* fix(claude): preserve dispatch cancellation fence

* test(claude): cover provider-opened stop integration

* fix(claude): derive dispatch cancellation fence from journal

* fix(claude): honor journal dispatch status before local sends

* fix(native-chat): omit absent dispatch observation

* fix(claude): release unresolved stop fence after deadline

* fix(claude): bound and poll dispatch admission wait

* test(claude): cover dispatch admission fast path
2026-09-15 16:54:43 -07:00
Brennan BensonandMerge Sim 6da72383df feat(agent-launch): one executor for agent launches, exposed as agent.launch (#19849)
* refactor(agent-launch): make the launch-mode decision surface-neutral

`decideWorkerStartMode` was the only shared answer to "structured chat session
or terminal agent?", but it lived in an orchestration-named module and spoke
orchestration's vocabulary, so the other launch surfaces could not call it.
Move the decision to `main/agent-launch/agent-launch-mode` unchanged and leave
`orchestration-worker-start-mode` as the adapter that supplies the noun.

A worker is not a special kind of launch; it is the same launch with a dispatch
attached. Naming the receipt's subject is the only thing orchestration actually
contributed, so that is the only thing the adapter keeps: "worker" in both
sentences, plus the `--terminal` wording, which reads as nonsense anywhere a
`--terminal` flag does not exist. Both are pinned, because they are asserted.

No behavior change. The receipts are byte-identical for every reachable case,
proven by running the new pin against both implementations.

Also pins the wording, which nothing was holding. The existing suites assert
`toContain` fragments ('terminal agent', 'cannot create') and the CLI suite
asserts a receipt handed to it by a mock rather than one this code produced;
all six files stayed green against a deliberately corrupted vocabulary. A
dispatch receipt is the only place a structured-to-terminal downgrade explains
itself, so the whole sentence is the contract, not a fragment of it.

* feat(agent-launch): add the launch intent and the one executor that runs it

The sequencing around the launch decision was duplicated per surface, and the
duplicate is where the bug lives. A new worktree was created agent-first, so
its startup terminal WAS the agent and the structured branch below it could
never be reached — every new-worktree launch was a PTY regardless of the user's
default. Orchestration fixed that for itself in #19431; mobile and the CLI
still have it.

`executeAgentLaunch` inverts the order once, for everyone. When the preference
is structured the worktree is created with NO startup agent, the executing host
is then asked whether it can host a session for the workspace that now exists,
and only then is a surface created. The host verdict cannot be hoisted above
creation: `agentSession.createSupport` only answers for a workspace it can
resolve, which is why the decision stays in two halves.

Agent-first creation is deliberately preserved for PTY launches — it is what
sequences the agent's startup command behind the setup runner, so wait-for-setup
comes for free there.

What actually differs per surface is only how a surface is built (an
orchestration worker's session takes a dispatch hold and a mailbox a plain
launch must not take), so that is injected as a factory rather than branched on.

The intent also strips the reserved agent fields from a migrated create payload:
a caller moving off `worktree.create` passes its existing params, and a stale
`startupAgent` in there would re-create the very path this replaces.

Tests assert order and arguments, not just the resulting mode. Reintroducing
agent-first creation reddens 4 of 11.

* feat(agent-launch): expose the launch executor as the agent.launch RPC

Adds `agent.launch` — one host-side method that decides structured-vs-terminal and
creates the surface — wired to the real runtime factories: `createManagedWorktree`
for the workspace, forking on `startupAgent` exactly as the orchestration worker
path does; `createStructuredAgentSessionForWorktree` for a chat session; and
`createTerminal` for a PTY agent. Allowlisted for mobile, which is the surface the
routing gap was reported on.

`worktree.create` is untouched. Its `startupAgent` keeps meaning "spawn a PTY agent"
verbatim, because it answers with `agentTerminalHandle` only on that path: a host
that quietly routed it to a structured session would hand every older client a
response with no handle and no error. All new behaviour sits behind
`agent.launch.v1`, which the host now advertises and a remote client must negotiate,
so a client that does not gets today's behaviour unchanged.

* fix(agent-launch): drop the deleted draft-prompt blocker from the reason map

main removed the draft-prompt blocker in #19681 (a structured session now holds
an unsent draft), so the exhaustive Record no longer typechecks.

* chore(agent-launch): carry a SAFETY rationale on the agent placement cast

The type-assertion gate landed after this branch's base, so the new file's
copy of the worker-start cast is now a changed-code finding.

* chore(agent-launch): carry agent.launch through main's RPC typing and casting gates

The typed-method contract, the generated params catalog and the
`assertionStyle: never` casting scan all landed after this branch's base.

- AGENT_LAUNCH_METHODS kept an `RpcMethod[]` annotation, which widened its
  method name to `string` and broke assignability; every sibling infers instead.
- `agent.launch` binds a schema under src/main, so it joins the catalog's
  RPC_METHODS_WITHOUT_SHARED_PARAMS and the parity gate's hand-listed twin.
- The now-typed methods make most test casts unnecessary; the few that remain
  carry the line-specific SAFETY rationale the casting gate requires.

* docs(agent-launch): stop the receipt-wording comment claiming a migration

The decision was never moved out of orchestration-worker-start-mode; this PR
adds a second copy beside it. Say so, and name the unenforced agreement.

* docs(agent-launch): stop the executor comment claiming a migration that has not happened

The header asserted two things the tree does not support: that every launch
surface routes through the executor, and that the mode decision "already lived"
in `agent-launch-mode`. `agent.launch` is the executor's only consumer, and
`orchestration-worker-start-mode.ts` is byte-identical (blob 92dc5c644a, 217
lines) at the merge base and all three stack heads, still used by workers.ts.
Describe the two live copies and leave the cutover to later stack work.

* fix(agent-launch): preserve setup and refusal fallbacks

* fix(agent-launch): dedupe complete launch and cancel setup wait

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-15 16:35:32 -07:00
Neil 13ba649c22 fix(terminal): let a runtime-created Windows terminal BE the requested shell (#20825)
* fix(terminal): let a runtime-created Windows terminal BE the requested shell

`orca terminal create --environment <windows-host> --command 'cmd.exe'` never
created a cmd terminal. `--command` is text the provider TYPES into whatever
shell it spawned, so the PTY stayed the host's default shell with cmd running
inside it. Captured on `awin`, whose default is Git Bash:

    $ orca terminal create --environment awin --command 'cmd.exe' --json
    $ orca terminal send --environment awin --terminal term_10656cf7... \
        --text exit --enter
    $ orca terminal read --environment awin --terminal term_10656cf7... --screen
      neil@awin MINGW64 ~/orca/orca ((30f820708f...))
      $ cmd.exe
      Microsoft Windows [Version 10.0.26200.9445]
      C:\Users\neil\orca\orca>exit
      neil@awin MINGW64 ~/orca/orca ((30f820708f...))
      $

The handle is alive the whole time and `terminal list` shows one healthy
terminal, because the PTY never changed — so the only symptom is that the
caller's terminal is now a shell it never asked for, and every later `send` is
quoted for the wrong one. On `win-lowspec` (default pwsh) the same create lands
cmd inside PowerShell.

Root cause
----------
There are two spawn preflights and they are twins:

- `src/main/ipc/pty/ipc/spawn-preflight.ts` — renderer/IPC spawns, i.e. a
  terminal tab opened in the app.
- `src/main/ipc/pty/runtime/spawn-preflight.ts` — runtime spawns: the CLI's
  `terminal.create`, headless `orca serve`, and every paired remote
  environment.

Only the IPC twin read the caller's requested shell. The runtime twin passed a
literal `requestedShellOverride: undefined`, so a runtime-created terminal on
Windows could only ever be the host default. Everything downstream of that
point — `spawn-options`, the daemon, `resolvePtyShellOverride` in the relay,
`local-pty-launch-plan` — already honoured `shellOverride`; nothing upstream
could supply one.

Change
------
- Thread `shellOverride` through the runtime lane: `RuntimePtySpawnArgs` ->
  runtime `spawn-preflight` -> `RuntimePtyController.spawn` ->
  `TerminalCreateOptions` -> the `terminal.create` RPC's new `shell` param ->
  `orca terminal create --shell`.
- Thread it through the renderer-backed lane too (`createDesktopTerminal` ->
  `terminal:requestTabCreate` -> `store.createTab`), so `--shell --focus` is not
  silently dropped on a local Windows app.
- An agent launch quotes its startup command for the shell it will actually run
  in, so a requested shell now owns the startup-shell family instead of the
  global `terminalWindowsShell` setting.
- Lift the relay's `ALLOWED_WINDOWS_SHELL_OVERRIDES` into
  `isSupportedWindowsShellOverride` in `src/shared/windows-terminal-shell.ts`
  (membership unchanged) so the CLI, the zod param schema, and the relay refuse
  the same names. `--shell` therefore cannot carry a path or a command line into
  `pty.spawn`; only allowlisted bare shell names pass.
- Gate on `TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY`. An older host
  strips the unknown `shell` param and answers with a healthy terminal running
  its default shell — a reply indistinguishable from success — so the CLI
  refuses before creating anything rather than creating the wrong shell quietly.

`--shell` stays Windows-only; macOS and Linux hosts spawn the login shell and
the relay drops the value off win32 rather than honouring it half-way. A WSL
project runtime still outranks it, unchanged.

Tests
-----
- `pty-spawn-shell-override-parity.test.ts` pins both preflights against the
  exact drift that caused this (verified failing with the fix reverted).
- `createTerminal` passes `shellOverride` to `ptyController.spawn` with no
  startup command.
- CLI: sends `shell`, refuses a shell the host cannot spawn, and refuses a host
  without the capability — in both refusals without making the round trip.
- Allowlist and `terminal.create` schema accept/refuse cases, including paths
  and appended arguments.

* fix(terminal): refuse a requested shell the execution host cannot apply

The first commit made `--shell` reach the spawn, but only a LOCAL win32
execution host applies it: `spawn-options` gates the override on
`process.platform === 'win32' && !args.connectionId`. So `--shell cmd.exe`
against an SSH-routed worktree, or against a macOS/Linux host, still returned a
healthy terminal running that host's default shell — the same
indistinguishable-from-success reply the capability gate exists to prevent, one
layer down.

Refuse instead, before anything spawns. The check sits at the top of
`resolveAgentTerminalCreateOptions`, which every create lane funnels through, so
neither lane has to remember it; the desktop lane additionally refuses a
worktree-less create, which has no execution host to resolve a shell on.

An SSH host's platform and installed shells are not visible to this runtime, and
a POSIX host has no Windows shell to pick. Neither can honour the request, and
saying so is the whole point of the flag.

Docs and the CLI spec now say "refused", not "ignored".

* fix(terminal): refuse a shell that contradicts the project execution runtime

`resolveLocalWindowsTerminalRuntimeOptions` does not merely rank the project's
execution runtime above a per-terminal pick -- it REWRITES the pick, in both
directions, and says nothing:

- a WSL project forces `wsl.exe`, discarding `--shell cmd.exe`;
- a Windows-host project discards a WSL name and falls back to `COMSPEC`
  (`getHostShellForProjectRuntime`), so `--shell wsl.exe` spawns cmd. That is
  the common case, not an edge: `resolveProjectExecutionRuntime` resolves
  `windows-host` for every project that is not WSL, while a repo belonging to no
  project honours `wsl.exe` -- so the same flag behaved differently depending on
  whether the repo was in a project.

Either rewrite returns a healthy terminal running a shell the caller did not ask
for, which is the failure `--shell` exists to remove.

It also split an agent launch's quoting from the shell that receives it. The
previous commit made the startup-shell family follow the REQUESTED shell, so
`--shell wsl.exe --command codex` on a Windows-host project typed POSIX-quoted
launch args into cmd. Refusing the contradiction removes that case rather than
papering over it.

Refuse instead, alongside the SSH and non-Windows refusals, from the same
`resolveAgentTerminalCreateOptions` seam every create lane funnels through.

Also from review:
- the allowlist test looped the list against itself; spell the members out.
- the runtime spec case claimed to prove the pty's shell when it asserts the
  controller received the field; name it for what it checks.

Reported by an adversarial review of the branch.

* fix(terminal): canonicalize --shell and refuse a WSL-path rewrite

Review of the --shell create path turned up two ways the terminal could
still end up being a shell the caller never asked for -- the exact failure
--shell exists to remove.

Bare and mixed-case spellings passed the allowlist but reached consumers
that exact-match the canonical name: resolveWindowsShellStartupFamily
classified `cmd` as the PowerShell family, resolveWindowsShellLaunchArgs
fell through to empty shellArgs (no `chcp 65001`, no OSC 133 bootstrap that
Windows foreground status depends on), and resolveWindowsGitBashShellPath
compares case-sensitively so `Git-Bash` spawned a literal `Git-Bash`.
The allowlist is now one canonical-name map and terminal.create canonicalizes
on parse, so the spawn path only ever sees `.exe` spellings. `pwsh` and
`powershell` stay distinct binaries.

A `\\wsl$\<distro>\...` cwd made the providers force wsl.exe regardless of
the request, and terminalShellOverrideRefusal only inspected the project
runtime -- undefined for a folder workspace with no project. Refuse on the
resolved cwd and the workspace path, judging what the PTY actually gets.

Also: the capability gate reported an unreachable host as too old rather
than unavailable; the SSH CLI shim dropped capabilities from status, so
--shell there blamed the host version instead of naming SSH; and --shell
had no help entry, rendering bare in `orca terminal create --help`. Adding
that entry crossed help.ts's max-lines cap, so the flag table moved to
flag-help-text.ts rather than suppressing the rule.

Adds a behavioural test for the runtime preflight (the one-line fix was
pinned only by a source-text scan), plus coverage for the startup-command
quoting family, the no-workspace refusal, and the WSL-path refusal.

* fix(build): keep tests out of the RPC params catalog bundle

The catalog walk under methods/ already skips *.test.ts, but the contract
directory glob took every .ts. terminal-create-shell-param.test.ts is the
first test to live there, so the bundle pulled vitest into a CJS build and
the generator threw on require(). Same exclusion, same reason.
2026-09-15 16:34:16 -07:00
Brennan Benson b5a99462bc fix(e2e): fabricate runtime-host health through the snapshot that owns it (#20762)
* fix(e2e): seed the runtime host with a snapshot the host cannot overwrite

Since #20003 the published snapshot owns runtime-host health, so a bare status
seed no longer survives: main's status owner publishes `checking` for this
unreachable synthetic host as soon as any runtime RPC touches it, the host
reads `connecting`, and the Add Project dialog falls back to Local — so the
host-scoped copy the test asserts never renders.

Seed a snapshot pinned at the top sequence instead. applyRuntimeHostStatusSnapshot
drops any later publication whose sequence is not higher, and setRuntimeEnvironmentStatus
no-ops a snapshot-less write once a snapshot exists, so the bare seed could not
have worked either way.

Ablated: passes with the pinned snapshot, fails without it.

* fix(e2e): fabricate paired-web host health through the snapshot that owns it

Companion to the onboarding seed fix. Since #20003 the published snapshot owns
runtime-host health, so writing `status: null` alone leaves the paired-web
client's verified/ready snapshot in place: addRuntimeHost reads
snapshot.transport 'ready' before it ever consults status, and the host still
renders Connected while the test waits for Disconnected.

Rewrite the snapshot coherently instead, pinned at the top sequence so the live
status owner cannot restore the host mid-assertion. The disconnected leg uses
transport 'unknown' rather than 'disconnected', because a dropped transport is
unverifiable and renders as Connecting; only a never-reached host renders
Disconnected. remoteControl is nulled because runtimeControlHealth answers
'available' on a ready control socket even with a null status.

Ablated with ORCA_E2E_WEB_CLIENT=1 (the whole file is test.skip'd without it,
so a run without that flag reports a passing skip): fails without the change,
passes with it.

* test(e2e): preserve paired runtime status metadata
2026-09-15 16:18:36 -07:00
Jinwoo Hong 0869d997c8 refactor(mobile): send the rest of the session domain through typed RpcOperations (step 4) (#20891)
* test(mobile): record the session domain's remaining call sites before migrating them

Freezes main's behaviour for what is left of `mobile/src/session/`: the AI Vault resume pair, the
clipboard image upload and its two attachment surfaces, the native-chat terminal writes, session
tab activation and reconciliation, the terminal-path tap, the structured agent launch, and the
session screen's own reads, tab creates/closes, review actions, notes, markdown save and quick
commands. 87 scenarios over 22 new families, recorded from the pinned baseline
`c6a72169843ececf3a21da370ac50c5c5a4e6462` through a detached worktree, before any product edit.

Ten new modules under `adapters/`, so each domain's goldens are pinned by `adapterSha256` and no
existing family re-digests. `native-mounting-substitutes.ts` gains `expo-haptics` (inert: every
caller is already fire-and-forget), `expo-clipboard` (a per-recording pasteboard cell, because
these screens read back what they wrote) and `BackHandler`/`Keyboard`. That is an engine file, so
all 509 pre-existing goldens move on `recorderSha256` and on nothing else.

Two families cannot be matrixed at their first request: the clipboard upload chain puts the start
reply's `uploadId` into the params of every later call, so a partition that answers the start
differently changes a downstream assertion rather than a recorded observation. Their base scenarios
stop at that first reply instead, and the fallback arm carries the upload family's second site.

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

* test(mobile): type the new session mount fixtures against the product model

The ten adapter modules added with the session recordings typed several fixtures structurally —
loose maps, a local `Terminal`/`Tab` shape, `unknown` for the review screen state — which
`pnpm --dir mobile typecheck` rejects: `tsconfig` covers `src/test-support`, so an adapter is
checked like product code even though no test file is. Each one now names the product type it
stands in for (`MarkdownDocState`, `TerminalRecord`, `MobileSessionTab`, `ReviewScreenState`,
`MobileDiffReviewQueueItem`, `DiffComment`) and supplies its members through `mountFixture`.

Only one of those changed a recording: a `DiffComment` requires `side`, so the review actions now
put it on the wire and the two `review-mark-reviewed-*` goldens carry it. That is the fixture
becoming a real subset of the type it claimed, not a behaviour change — no product source moves in
this commit, and the parity claim the next one makes is against these bytes.

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

* style(mobile): format the session mount adapter and the recording manifest

`pnpm exec oxfmt --check .` from `mobile/` flagged both after the previous commit's type fixes.
The adapter is pinned by `adapterSha256`, so the eight goldens recorded through it are re-recorded
from the same baseline; the manifest is not pinned by its bytes — `scenarioSha256` canonicalises
the parsed scenarios — so no golden moves for it. Header-only either way: no observation changes.

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

* refactor(mobile): send the rest of the session domain through typed RpcOperations

Thirty files and 58 references leave the raw request port. Every send now names a declared
operation with a fixed method, one acceptance policy and one reader, and no call site casts a
reply payload: the AI Vault resume pair, the clipboard image upload with both its attachment
surfaces, the native-chat terminal writes, session tab activation and reconciliation, the
terminal-path tap, the structured agent launch, and the session screen's reads, tab
creates/closes, review actions, notes, markdown save, quick commands and file search.

Five new operation modules, plus two readers added to existing ones. Three methods get a second
reader, each argued where it is declared: `files.resolveTerminalPath` (the tap branches on five
members the grant refresh hands back whole), `files.open` (the tap's miss is silent, the Changes
screen raises the host's message) and `session.tabs.list` (the send sheet keeps only terminal
tabs, which the reveal poller and the reconciliation controller both drop). `git.stage` carries
two acceptances for the same reason `repo.list` does: a tapped file raises its refusal, a bulk
sweep counts it. No new acceptance policy.

The AI Vault resume launch and the review send sheet now share
`mobile-review-terminal-operations.ts` with the PR triage launch instead of re-deriving the same
create/send pair, and the file-search hook's `extractPaths` moves into the reader it belongs to.

One latent behaviour is preserved rather than fixed and wants a ticket:
`mobile-session-tab-activation.ts` decides whether to replay an activation with
`error instanceof LogicalClientCutoverError`, not with the message-matching
`isLogicalClientCutoverError` that exists because "instanceof can miss across bundle copies". Under
a second copy of the module the retry silently does not happen, which the recorder reproduces. No
golden scripts a cutover for this family, so none records the wrong behaviour.

The offender floor in the port ratchet comes down from 50 to 20: it is an anti-vacuity guard on a
list this migration is driving to zero, and 39 files still reach the port.

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

* fix(mobile): keep main's malformed-reply message and re-anchor two mutants

Three findings the reply matrices surfaced that the pilot scenarios could not.

The clipboard upload's `uploadId` read goes back to the raw result. A success carrying no result
throws a destructuring TypeError there, and V8 puts the destructured expression's source text in
the message — which the composer shows. Reading the slot off the interpreted payload rewrote that
sentence for every user who hits a malformed reply, on three families' `result-absent` and
`result-null` partitions. The cast is the one main made, kept for the message alone.

`race` and `new-tab-refusal-order` both anchored in text the migration rewrote, so each matched
zero sites. Re-anchored at their new homes; the defect each injects is unchanged, and
`probe-hole-witness.test.ts` still shows the probe killing the reorder while every pre-probe
scenario survives it.

`native-chat-send-delivery-unknown` is added as this domain's own mutant: dropping the
delivery-unknown arm of a chat send makes an ack lost after the frame was written read as a
definite rejection, which invites the user to send the same message twice. A second candidate —
swapping `terminal.list` from skip to throw — survives every golden and is not registered: the
inventory hook wraps its whole read in `catch { return false }`, so a refusal and a throw leave
the same strip.

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

* test(mobile): matrix every reply the new session families script

Thirty-four reply-matrix goldens, one per scripted reply across the twenty-two families the session
migration added, each running the eleven partitions in `reply-matrix.ts`. Recorded from the same
pinned baseline as the pilot goldens through the detached worktree, so they freeze main's answer to
a result-less success, a null result, an inner refusal envelope, a message-less outer refusal and a
transport drop — not the migrated code's.

They are laid down after the refactor because they are what found its three remaining divergences,
each fixed in the previous commit rather than recorded around: the clipboard upload's destructuring
message, and two mutation anchors the rewrite left matching zero sites.

Two families matrix only their first request. The clipboard upload chain puts the start reply's
`uploadId` into every later call's params, so a partition that answers the start differently moves
a downstream assertion instead of a recorded observation; their base scenarios stop there, and the
upload family's fallback arm carries its second site.

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

* refactor(mobile): keep the new session readers inside the operation cast fence

`rpc-operation-cast-fence.test.ts` refuses a type assertion anywhere in the region reachable from
`rpc-operation-contract.ts`, and four of the new operation modules asserted their own result type.
Each of those reads goes back to `rpcUncheckedPayloadReader`/`rpcUncheckedMemberReader`, and the
shape the call site expects is named at the call site, which is where every migrated domain already
puts it. `extractPaths` and `readQuickCommands` return to their hooks for the same reason — both
were only movable by carrying a cast with them.

No golden moves: the readers hand back the same values, and all 705 recordings still compare clean.

The two frozen source-parity suites over the session route family are refreshed for the migrated
text: three hashes, one runtime-string count, and the inventory hook's acceptance check, which now
reads `!isCurrent() || !response.accepted`.

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

* fix(mobile): drop three assertions the changed-code casting gate rejects

`pnpm run check:code-quality:changed` reports `consistent-type-assertions` separately from oxlint's
own pass, and three sites had no rationale: the resume preparation's payload read, which gets the
standard SAFETY line, and two adapter refs whose `null as string | null` is just an annotation
written the wrong way round. The adapter is pinned by `adapterSha256`, so its thirteen goldens are
re-recorded from the baseline — header-only, no observation moves.

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

* refactor(mobile): answer round 1 on the session migration

Reuses `RpcOperationSender` for the file-tap client instead of respelling it as
`Parameters<typeof fileTapPathResolve.request>[0]` at two sites; the type is the same by
construction, and the handlers file no longer imports an operation only to name its first parameter.

Names why `worktree.set-review-notes` stays separate from source-control's identical
`worktree.set-review-link`, and why the new-tab loader's two preflight reads do not share the task
drawer's readers on the same methods.

Corrects the `callAgentSession` holdout count: five call sites across two hooks plus one inside the
module's own mutation wrapper, not seven callers across five files.

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

* docs(mobile): state what actually gates the native chat paging read

The `nativeChat.readSession` send sits in the paging callback, not in the mount effect. What blocks
recording it is that the mount effect's `nativeChat.subscribe` is what arms the offset and
generation the callback pages against, and the request-only runner refuses to open a subscription.

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

* refactor(mobile): answer round 2 on the session migration

The tapped-path resolve is now a re-export of the preview screen's `terminalArtifactPathResolve`
rather than a second definition: same method, same skip, same whole-payload read, so the only thing
the old comment could claim was a difference that was not there. `fileOwnershipRuntimeStatusRead`
already re-exports for this reason.

`worktree.show` has four readers, not three. The one the notes read is closest to is
`fileOwnershipWorktreeRead`, which reads the same member whole, and acceptance is all that separates
them: a file mutation throws rather than write to the wrong host, a session screen without its notes
shows none and keeps working.

`interpretOrThrowRefusalMessage` is generic, so a caller keeps the interpretation's own type, and
eight longhand copies of its try/catch across five files now call it. Three more copies wait on a
frozen source hash; see the report.

Three operations sit in the module matching their direction: the quick-command save with the writes,
the review-notes read and the markdown read with the reads. The quick-command reader is shared
across that line, which is what keeps the save from adopting `[]` on a payload the parser rejects.

The native-chat readability probe imports `MobileRuntimeRepoSummary` instead of redeclaring it, and
a stray mutant comment that described the race entry is gone.

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

* refactor(mobile): finish the refusal-interpretation helper across the session hooks

Twelve try/catch blocks across the session hooks now call the generic
interpretOrThrowRefusalMessage instead of rethrowing
refusedRpcMessageOrFallback by hand. Each one throws the same message on the
same inputs, and the request stays outside the catch, so a transport rejection
keeps its delivery-unknown identity.

Two frozen parity hashes move for that reason alone:

- HEAD_CALLBACK_BODY_SHA256, for the one converted block that sits in a
  useCallback (use-mobile-session-diff-comments.ts)
- HEAD_NESTED_FUNCTION_SHA256, for the three that sit in plain nested functions
  (use-mobile-session-content-create-actions.ts)

Every other parity hash and every count is unchanged: hooks 269, callbacks 77,
effects 24, nested functions 12, plus the callback identity, effect, main-hook,
hook-binding, content-hook, native registration/removal and timer hashes.

Copies that stay longhand, by design:

- the action-level try blocks that wrap the request as well as the interpret and
  surface the failure to the UI, in the diff-review comment, git and send hooks
- the two catches that call setActionError or setError and return instead of
  throwing, in use-mobile-diff-review-interactions.ts and use-quick-commands.ts
- the call sites in files this branch does not touch (pr-ai-triage-launch.ts,
  mobile-diff-review-loaders.ts, github-pr-rpc.ts,
  github-pr-mutation-outcome.ts)

No golden moves: this touches no golden, adapter or recorder engine file.

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

* test(mobile): re-record the goldens at the new baseline pin

One re-record of all 641 goldens against pin e7206f62a8 from a detached
worktree at that commit, with this branch's rpc-recording/ tree,
pilot-scenarios.json and scripts/rpc-recording.mts overlaid and only the
differing files copied in. The recorder's own fence passed: product sources
under mobile/src outside the recorder, src/shared and the lockfile were
byte-identical to the pin, with nothing untracked under the guarded trees.

Every golden now carries one baseline, one lockfileSha256 and one
recorderSha256. recorderSha256 moves on all 641 because this branch's recorder
tree adds adapters to main's, so main's own value could not survive the merge.
baseline moves on exactly the 121 goldens this branch adds, from c6a7216984
to e7206f62a8.

595 goldens are header-only. The 46 bodies that move are all among this
branch's 121 and all gain the `sent` ordinal that #20884 added to effects,
verified field by field: each effect is byte-identical apart from the new key,
and no effect was added or dropped. None of main's 509 bodies move, and the
six-golden agentSession.structured-launch family does not move either.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 19:16:45 -04:00