mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
+10




![github-actions[bot]](https://secure.gravatar.com/avatar/af2ab225b7c0eec44a8d0eba6b5c869a?d=identicon&s=40)



2ce9003414
* fix: gate startup agent recovery on host inventory * fix: defer recovery while a live agent lacks conversation ownership * fix(mobile): two known main bugs the RPC migration preserved (#20563) * fix(mobile): two known main bugs the RPC migration preserved A malformed host `error` and a null settings result both reach a property read that throws. Both are deliberate behaviour changes; the goldens move in the follow-up commit. `hostReplyErrorTextOrFallback` passed a truthy non-string through under a `string` annotation. Its one caller is the in-band `git.commit` failure, and every consumer of that text is display or prompt copy: `use-mobile-create-pr-runner` and `PrSidebarCreateEmptyState` record it as a commit failure, `use-mobile-commit-failure-recovery` hands it to `summarizeCommitFailure`, which starts with `raw.slice(...).replace(...)`. So no consumer needs the value, and the decision is the fallback rather than `String(value)` — the relay handler declares `commit(): Promise<{ success: boolean; error?: string }>`, so a non-string is a malformed reply, and `generatedCommitMessageReader` in the same domain already reads a non-string host error as absent. The parameter stays `unknown`, which it honestly is, and the `SAFETY` cast is gone. `useNewWorkspaceRuntimeContext` read settings through `settingsRead`, whose reader preserves main's `boxed!.settings` throw, so a `null` or absent result threw a TypeError out of the effect — losing the trusted-hooks publish and the available-provider computation that follow it, not just the settings. It now uses `optionalSettingsRead`, the operation that already reads a null or absent result as absent settings, so the reply degrades exactly the way a reply with no `settings` member does. Reply-side only: same method, same params, same barrier, no wire change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens the two bug fixes move Baseline bumped to3f71999237. Two goldens move an observation; the other 151 move only `baseline` and `recorderSha256`, which `pilot-scenarios.json` is still digested into. Observation moves, one claim each: - `matrix-hostedreview.create-intent-git.commit-1`, partition `inner-false-object-error`: `settlements.run.value.error` and `state.outcome.error` go from `{"message":"inner refused"}` to `"Commit failed"`. A non-string in-band `git.commit` error is a malformed reply and now reads as the screen's copy, converging with `result-absent`, `result-null` and `outer-refused-no-message`, which already reported the fallback. The other ten partitions at this site are unchanged. - `matrix-settings.workspace-context-settings.get-1`, partitions `result-null` and `result-absent`: the `unhandled-rejection` TypeError effect (`reading 'settings'`) is gone and `state.providers` goes from `[]` to `["github"]`. The effect no longer aborts the rest of the hook, so the provider computation runs; `state.settings` stays null because nothing was published, which is how a reply with no `settings` member already degraded. The other nine partitions are unchanged. Header-only moves: - 9 goldens of the `settings.workspace-context` family rename `namedDeltas` from `new-workspace-runtime-context-null-settings-typeerror` — the name now lies, the TypeError is fixed — to `new-workspace-runtime-context-null-settings-degrades-to-absent`. - All 153 move `baseline` and `recorderSha256`. The digest covers `pilot-scenarios.json`, so the baseline bump and the rename re-digest every file. No sender recording moved: both fixes are reply-side, and no golden's `sender` or `payloads` field differs. The README paragraph that claimed the settings TypeError was preserved is updated, and now records that the `ui.get` leg of the same hook still is. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): degrade a null ui.get result the way the settings leg now does One host answers both legs of useNewWorkspaceRuntimeContext, so fixing only settings.get left the likelier failure in place: a null or absent ui.get result still threw `reading 'ui'` out of the effect, skipping the provider commit. Review follow-ups on the same files: reply() returns the literal uncast and the stub client is FakeSession, dropping two assertions and their SAFETY disables; the degradation cases now assert absolute state instead of comparing mounts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens the ui.get leg fix moves Observational move, 1 golden: - matrix-settings.workspace-context-ui.get-1: the `result-absent` and `result-null` partitions drop their `reading 'ui'` unhandled-rejection effect and their state commits `providers: ["github"]` instead of `[]`, because the effect no longer throws before the provider commit. Header-only moves, 153 goldens: `baseline` to the fix commit and `recorderSha256`, which covers `pilot-scenarios.json` and so re-digests on the delta rename. The delta is renamed `new-workspace-runtime-context-null-settings-degrades-to-absent` -> `new-workspace-runtime-context-null-results-degrade-to-absent` (9 goldens): it now covers both reads, not just settings. README updated to match. No sender recording moved: resolving the value pool across all 153 goldens shows `sender` and `payloads` byte-identical everywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): name the ui.get result shape so the changed cast carries a rationale The inline union wrapped over four lines and tripped the changed-code casting gate as a new assertion; a named alias keeps the cast on one line under a SAFETY note. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the golden baseline to the cast-rationale commit Header-only: `baseline` on all 153 goldens. The re-record is inert — no golden moves observationally and no field other than `baseline` changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ui.get trust blank, and correct two stale acceptance comments The goldens cannot catch a regression to `if (uiResult?.result)`: the scenario's success reply is `{"ui":{}}`, so every partition of matrix-settings.workspace-context-ui.get-1 records the same `trust:{}` state. The new case answers once with real trust and again with a null result on a fresh client, which is the only shape where skipping the blank is observable — trustedOrcaHooks gates the setup-hook approval prompt in use-new-workspace-create-submit.ts, so a stale value would skip it. settingsRead's comment still claimed workspace context, which this branch moved to optionalSettingsRead; both comments now name their real callers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the golden baseline to the trust-blank commit The record fence rejected the previous pin ("Product sources or lockfile differ from the pinned main baseline"), so the branch was no longer re-recordable. Header-only: `baseline` and `recorderSha256` on all 153 goldens — the digest covers pilot-scenarios.json, whose only edit is that baseline. The re-record is inert: 0 goldens move observationally and no other field changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the right operation per refuse-after-data probe Three of the five probes read through optionalSettingsRead, not settingsRead: repo metadata and resume metadata already did, and workspace context does as of this branch. The sentence now splits them and states why the split does not move what the probes record. Markdown is excluded from recorderSha256, so no re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the recorder's unhandled-rejection capture This branch removed the last two goldens that recorded an unhandled-rejection effect, so nothing exercised unhandled-recording.ts any more: gutting the emit to `void captureError(error)` leaves all 153 goldens comparing clean. The unit test drives a detached rejection through the window and asserts both the effect and the listener restore. README says so where it describes the capture. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-digest the goldens for the new recorder test Header-only: `recorderSha256` on all 153 goldens, which covers every non-markdown file under rpc-recording/ and so moves for the added test file. The re-record is inert: 0 goldens move observationally and no other field changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin each RPC golden to its own scenario input, not the whole manifest (#20562) * refactor(mobile): pin each golden to its own scenario input, not the whole manifest `recorderSha256` covered the recorder directory plus `pilot-scenarios.json`, so every golden's header was a function of every other family's scenarios. Adding a family for one domain re-digested all 153 goldens and put a conflict on that line in every domain branch in flight, which serialized the step-4 fan-out. Split the two things it conflated. `recorderSha256` now covers the recorder directory only, with unchanged semantics: a recorder edit still forces a full, deliberate re-record. A new `scenarioSha256` pins the scenario input that golden was recorded from — every scenario `runRecording` consumed for it, in order — canonicalised through `captureValue` so an explicit-undefined param stays distinct from an absent one. `goldenRecording` takes that list instead of just its first member. The variants are hashed rather than the base they expand from because they are what was recorded: a matrix site, its replayed normal result and its partition replies are all visible in them without the derivation having to be restated. `derived-goldens.ts` is that derivation, extracted from `family-recordings.test.ts` so the digest and the recording agree by construction — a property test that restated how a matrix or schedule expands could agree with itself and with nothing else. It reproduces exactly the 153 golden ids on disk, and the census the suite already ran (every family matrixed, no stale normal-result inventory entry) now reads off its output. `golden-header-digest.test.ts` pins the four properties: - a new family in the manifest moves zero existing goldens' headers, and derives two of its own - editing one field of `b1` moves exactly `b1` and its family's four matrix goldens — not the two other `legacy-inventory` scenarios, and not the goldens that expand from `inventory-lifecycle` - editing a recorder file still moves every golden's `recorderSha256`, and no `scenarioSha256` - `recorderSha256` is unchanged by the manifest's contents, and no longer reads the file at all `GOLDEN_FORMAT_VERSION` goes to 4: a version-3 header has no `scenarioSha256`, and `compareGolden` walks the expected header's keys, so a reader that accepted one would compare that golden's own scenarios as though they were unpinned. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the 153 RPC goldens for the split digest Recorder edit, so every golden needs rewriting. Recorded from the pinned baseline `16d1ab81d3` with this branch's recorder overlaid, per the README's procedure: main has moved past the baseline, so recording in place would have failed the product-source fence. Three header fields moved and nothing else did: - `recorderSha256` 6a12160a87… -> 2fda557f58…, one value across all 153 files - `scenarioSha256` added, 153 distinct values - `goldenFormatVersion` 3 -> 4 No observation, checkpoint, value-pool entry, `baseline`, `lockfileSha256` or `platform` changed: git diff -U0 -- mobile/rpc-foundation | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' \ | grep -vcE 'recorderSha256|scenarioSha256|goldenFormatVersion' 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): certify the pilot goldens from the derivation that digests them `pilot-recordings.test.ts` restated `[scenario]` instead of consuming `pilotGoldens`, so the claim that a golden's `scenarioSha256` is a function of the same derivation that records the file held only for the 75 family goldens: dropping a scenario from `pilotGoldens` left the whole suite green and put that golden outside the header oracle. The pilot suite now iterates `pilotGoldens`, and a census fails if the derivation and the goldens directory disagree in either direction — which also closes the pre-existing orphan-golden gap. Also from review: pin the cross-sibling replay that hashing the generated variants buys (a matrix golden's `normal` partition replays a sibling's recorded reply, so editing that sibling must move it); state the real reason for the format bump, which is the diagnosis a version check gives rather than a rejection the byte compare already made; drop the fourth property test, which re-proved what tests 1 and 2 and `recording-runner`'s digest test already fail on; and drop a guard in `scenarioSha256` that its only caller reaches after an identical one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the 153 RPC goldens for the review edits Recorder files changed, so `recorderSha256` moved. Recorded from the pinned baseline with this branch's recorder laid over it, per the README's migration-branch procedure. That one header field is the only line that moved in all 153 files: `scenarioSha256` and `goldenFormatVersion` are unchanged, and no observation moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the recording suite's test count Round-2 review: the README said 200 tests; the suite is 209 after the five added here. Markdown is outside recorderSha256, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the 153 goldens on the merged baseline Four header fields moved and nothing else. Proven against origin/main: every changed line in all 153 files is one of these, and the file set is unchanged. - `recorderSha256` 70aa6f59e0 -> 58a461dbc9: this branch's recorder, and it now digests only the recorder directory, not the scenario manifest. - `scenarioSha256` added, 153 distinct values over 153 goldens. - `goldenFormatVersion` 3 -> 4 for that added field. - `baseline`5ec0b2698f->e53f1557e1, the merge's repoint onto the real main commit. #20563's value was a branch commit the squash left unreachable, so the record fence's `git diff <baseline>` could not resolve it. No checkpoint, value pool, effect or settlement byte moved, so main's recorded behaviour is carried over intact. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): account for main's added recorder test in the suite count The merge brought in `unhandled-recording.test.ts`, one test, so the recording suite is 210 rather than the 209 this branch documented. Markdown is excluded from `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows (#20581) * fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows A structured chat pane could latch "Working for N" forever after the agent had finished, showing the send arrow rather than Stop, while the sidebar and `worktree ps` correctly read idle. The client replica has one position (`state.cursor`) and one body. Two operations keep those consistent: replace (both from one host snapshot) and append (rows contiguous with the cursor). The `tail-page` branch was a third thing: it took the cursor from the journal head, the items from a bounded page (200 items, byte-capped), then merged retained client submissions over the page's. Under continuous journal writes the client is always slightly behind, so the branch ran on every window focus and on every pane re-activation. When more than a page of rows had landed since a send, that send's user item fell off the page, its submission was not carried, the retained `pending` survived, and the cursor jumped past the dispatch-acceptance row. Nothing re-sends it: a batch carries only touched items and that submission is never touched again. Delete the third operation rather than guard it. A live subscription is now the only thing that moves the cursor, and `subscribe({ cursor })` already replays exactly the missed rows. - remove the window `focus` listener and the owner/transport `refresh` contract - skip warm hydration: a retained owner subscribes at its applied cursor - cold hydration keeps its history read, applied as the existing `snapshot` (replace) event rather than `tail-page` - delete the `tail-page` action and its reducer branch - delete `resumeCursor` and `shouldAdvanceStructuredResumeCursor`; two cursors with two advancement rules were how position and body drifted apart `older-page`/`loadOlder`, the unattached-refusal grace, generation guards and the coalescer are unchanged. No host, wire or schema change. Also fixes a second cost of the same branch: focus during a busy turn discarded paged-in older items, shrinking the transcript to one bounded page mid-turn. * fix(native-chat): preserve unavailable mixed-version session fences * refactor(mobile): send the task workspace-creation domain through typed RpcOperations (#20568) * test(mobile): record main's task workspace-creation RPC behaviour before migrating it 28 scenarios over nine task senders, recorded from main so the step-4 migration of the workspace-creation half of src/tasks/ has a frozen answer to compare against. Four senders mount as plain exported functions; three are model-chained hooks mounted the way the settings adapters mount theirs. The 153 existing goldens change header-only (`baseline`, `recorderSha256`): any new scenario re-digests the recorder, and the pinned baseline had drifted from main because the source-control migration landed. Content is byte-identical on all 153 — verified field-by-field against HEAD. `operation-module-loader.ts` now shares src/transport/rpc-delivery-ambiguity.ts with mounted modules instead of evaluating a second copy. The mark is a WeakSet keyed on the rejection object, so the copy the loader built had an empty registry and every delivery-unknown rejection read as a definite failure inside the operation under test — worktree.create's whole replay path was unreachable. With one registry, `tw-create-retry-ambiguous-after-drop` records the create still pending at the reconnect wait and abandoning at exactly 20000 ms, while the unstamped-create scenario records the same rejection surfacing at 0 ms. No existing golden moves: no other mounted module consumes the mark. `task-preferences-optimistic` is re-anchored above the send rather than across it, so migrating this file does not have to move the anchor. It still kills, and for the same reason: the preset the screen shows no longer follows the tap. Scenarios deliberately pin the empty-message refusals (`*-refused-empty-message`, `*-empty-message`), because a refusal with no message falls back to the screen's copy while a transport error with no message does not, and the two paths are easy to collapse when a call site moves behind an acceptance policy. Goldens: 153 -> 201, 2.9M -> 3.7M. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): format the recording manifest and re-digest the goldens `oxfmt --check` from mobile/ collapses a one-element `sites` array in each new scenario. The JSON value is unchanged — verified by comparing both files parsed and key-sorted — but the manifest is inside `recorderSha256`, so all 201 goldens carry a new digest. Every other field, header and observation alike, is byte-identical. Re-recorded in a separate worktree at the previous commit so the goldens stay attributable to main's product source rather than to the migration that follows. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): separate the goldens from the migration, and re-digest The previous commit accidentally carried the product migration alongside the manifest format, which both broke the commit that is supposed to prove parity and left the suite red: the digest was recorded without a comment move that a lint fix had made inside the adapter, so all 201 goldens failed their `recorderSha256` header. This backs the product half straight out again — the next commit re-applies it byte-for-byte — and re-records from the pinned baseline in a separate worktree carrying this branch's recorder, per the procedure in the recording README. Every field except `recorderSha256` is byte-identical to the previous commit's goldens on all 201 files, so no observation moved in either direction. The suite is green here with main's product source, which is what makes the next commit's "no golden changed" claim mean something. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the task workspace-creation domain through typed RpcOperations 12 of src/tasks/'s 37 raw-port files now send through a declared operation instead of the raw request port: 36 references to 0, leaving 25 files and 73 references for the provider item/detail/mutation half. No golden moved — `git show --stat` on this commit touches nothing under mobile/rpc-foundation/, which is the parity claim. Twenty-two operations over twenty methods, in four modules named for what they send: workspace create (create, PR/MR base resolution, create-time capabilities), workspace source (SSH connect/state, agent detection, repo hooks, sparse presets, ref search), task runtime (status, ui.get/ui.set, preflight, Linear status, settings.update) and the Smart picker's provider reads. Two methods carry two policies each, and both pairs are named. `status.get`: the Tasks screen cannot hydrate without it and surfaces the host's message, while create-time capability probing degrades to "no capabilities" and creates anyway — so one throws on refusal and one skips. `ui.set`: two sites await it, one is fire-and-forget and never interprets the reply at all. Both pairs share one reader, so no method has two readers. No new acceptance policy. worktree.create keeps its delivery-unknown contract. `request` returns the transport promise itself, so the retry loop catches the object the transport marked; two new tests assert `toBe(marked)` in one direction and that an unmarked rejection stays unmarked in the other, because a mark added on the way out would replay a create the host never received. `tw-create-retry-ambiguous-after-drop` records the create still pending at the reconnect wait and abandoning at exactly 20000 ms. Three sites still read the raw refusal envelope before interpreting, because the code or the message decides the route and no acceptance policy carries either through: the create retry needs the message for `isRetryableWorktreeCreateConflict`, and the paste lookup needs `method_not_found` to retire the slug probe host-wide. Both are documented at the site. The hydration barrier keeps raw requests inside its `Promise.all`. main's group rejects as soon as one leg rejects; `startRpcOperation` + `interpretAtRpcBarrier` would wait for the slowest peer and let a later policy surface a different error. Interpretation stays after the `stale` guard, where it was. `WorkspaceCreateParams` is now `RpcSendParams<'worktree.create'>` rather than `Record<string, unknown>`, which types the builder and the operation together; every field the three builders already sent typechecks against the host schema unchanged. `RpcSendArguments` now also makes params optional for a method whose params type has no required field, because `preflight.check` is such a method and main sent it none — requiring `{}` would have put a new object on the wire. The Mobile Tasks source-parity hashes move for the same reason bound settings requests moved them: the method string and the envelope read leave the screen. The signature diff is evidence rather than a re-pin — `semantics` is a pure deletion of 22 `rpc:` call signatures and 22 method literals with nothing added, statement/declaration/render/style counts are unchanged, and render tokens, styles and declarations are byte-identical. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the task workspace adapters at the sender/hook seam The single adapter file reached 344 lines against mobile's 300-line limit. CI lints every file, so this is red there even though the changed-code gate does not report it. Split along the seam the recording README already draws: exported async senders that take a client and need no React host, and the drawer's three model-chained hooks. No adapter body changed. Both files are inside `recorderSha256`, so all 201 goldens carry a new digest. Every other field is byte-identical, verified file by file. Re-recorded from the pinned baseline in a separate worktree carrying this branch's recorder, so the goldens stay attributable to main's product source. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the one-key unchecked reader a name Four readers were the same three lines: read one property off the reply, wrap it unchecked. `rpcUncheckedMemberReader` is the one-key sibling of the existing `rpcUncheckedPayloadReader`, so the annotation and the closure go away at each site. The pilot's `commitCompareEntriesReader` is converted too, so the helper has no longhand twin left to copy from. No behaviour change: the helper composes the same `rpcReadUnchecked` over `rpcPayloadMember`, including the property-read exception on a null result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the local arm of workspace agent detection `preflight.detectAgents` was the one migrated operation with no recorded coverage: the ssh adapter hardcoded `connectionId: 'ssh-1'`, so the detection effect's ternary only ever took the remote arm and the local call site could be repointed at another method without a golden noticing. The adapter now takes the connectionId as a parameter and registers twice; `tasks.workspace-ssh-local` mounts the same hook with no connection, which is the only difference the effect branches on. Recorded at the pinned baseline with this branch's recorder laid over it, so the new golden is main's behaviour and the migrated code has to reproduce it — it does. Goldens: two added (`tw-workspace-ssh-local-agents` and its reply matrix). The other 201 changed on `recorderSha256` only, because the adapter edit moves the recorder digest every golden pins. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive workspace create and the Linear list to a recorded wire Two operations passed a policy swap unnoticed, both because no golden reached their acceptance branch. `worktree.create`: the create hook's fixture resolved setup to a prompt, so all three settings.task-workspace scenarios stopped before the request and the only consumer that hands a refusal to interpret was never recorded. The adapter now takes the setup resolution as a parameter and registers a second family that resolves it, so createWorkspace runs to the wire. Two scenarios: a Linear item that creates directly, and a GitHub pull request that resolves its base first, which also puts this hook's built params — start point, generated display name, agent launch fields — in a golden for the first time. The existing prompt family is untouched, so its recordings still pin that branch. `linear.listIssues`: it appeared only in a non-base scenario, and the matrix reads the family base, so the family had no partition for it. The base now lists assigned issues after searching. Goldens: five added. Five moved beyond the digest, all derived from the smart-search base that gained the list leg. The other 198 changed on `recorderSha256` only. Recorded at the pinned baseline with this branch's recorder laid over it, so every new golden is main's behaviour. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the unreachable unmount branches from the task adapters Nothing dispatches `unmount` to these three adapters: the only producer is `lifecycleSchedules`, driven from a hardcoded five-id list that names no task-workspace family, and it pushes a `remount` right after, which these adapters would throw on. The branch read as lifecycle coverage that was never wired up. `dispose: hook.unmount` already tears the mount down. Goldens re-recorded at the pinned baseline because the recorder digest moved; `recorderSha256` is the only line that changed in all 208. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens at main's post-squash baseline Recorded from a detached checkout ofe53f1557e1(main's unmigrated product code) with this branch's recorder laid over it, so the parity claim stays non-circular. - `baseline` repinned toe53f1557e1on all 208 goldens; main pinned5ec0b2698f, a pre-squash branch commit not reachable from main. - `recorderSha256` moved on all 208 because this branch's adapters are in the whole-manifest digest. - 55 task-workspace goldens re-recorded at the new baseline. - No other line in any of main's 153 goldens changed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens under #20562's per-scenario digest Baseline repinned to50e752fc66and all 208 goldens recorded from that commit's unmigrated product tree with this branch's recorder laid over it. recorderSha256 moves on every golden because the task-workspace adapters live in the recorder directory. scenarioSha256 does not move on any of main's 153: the manifest only adds 31 scenarios and edits none, which is the property #20562 was built to give. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(ai-vault-search): construct the session search indexer in the scanner service behind a setting (#20516) * feat(ai-vault-search): persist agent-session search consent and retention Two booleans and nothing else: `enabled` and `historyDays`, off by default because building the index reads every transcript on the machine. No `paused` -- the PR 3 indexer is immutable, so every change is close-and-construct. The settings IPC normalizes a write like every other field and hands the change to the index; there is no UI for it until PR 8. * feat(ai-vault-search): hold one indexer and engine pair per host The object that owns a host's live index and the three recipes that change it. The indexer is immutable, so a settings change is close-and-construct, disabling is close with no replacement, and clearing is close, remove the database, construct. The new instance's first sweep purges a narrowed window and admits a widened one, so neither needs a code path. The database sits beside the scanner's parse cache, one file per host. A runtime with no node:sqlite can hold no index at all, which the Node 18 floor on orcad and the relay makes a real case rather than a hypothetical one. * feat(ai-vault): let the scanner child own the session search index The transcript reader runs in that child, so the index consumer has to as well: one read serves both the session list and the index. Three request operations (search, status, reconcile) and one fire-and-forget settings message carry everything a parent needs; main never opens the database file. The init frame becomes a factory because it is read at every spawn, so a respawned child sees current consent rather than the first frame's. A child holding a running index is never idle from the parent's side, so idle retirement is suppressed while the index is on -- retiring it would stop the reconcile loop until some later scan happened to respawn one. Both files this lands in were already at the max-lines ceiling, so three collaborators move to where they belong rather than being disabled around: the invalidation deadline into the class that owns invalidations, call cancellation and the start requeue into the call-state module, and orcad's flag parsing into its own file. * feat(ai-vault-search): register a search service on every host that answers Without a registered service a host answers no-service, which means "this host does not have the feature" rather than "the index is off". All three hosts now answer the second thing. The desktop forwards to the scanner child. orcad and the SSH relay daemon have no such child -- orcad ships only the watcher and daemon entries, and the relay's AI Vault sidecar runs the remote scanner, which publishes nothing to the transcript channel -- so on those two the index lives in the process that would drive its reads, gated on a runtime that has node:sqlite at all. The relay registers with consent off and no way to turn it on: nothing carries a setting to a remote host yet. That is the honest state, and it is still worth registering, because it is what tells a client the difference between off and too old. * test(ai-vault-search): price a warm pass over five thousand transcripts The number the reconcile interval will be revisited against, measured rather than argued: a warm sweep stats every file under every root, a warm cycle stats the newest N per agent, and neither reads what the index already holds. It does not tune the interval. * fix(ai-vault-search): answer the casting gate without assertions main's new type-assertion rule reaches every file this branch touches. All nine sites drop the cast rather than carry a SAFETY: rationale: the operation guard narrows with `in`, the sqlite probe narrows the builtin it loads, the child test keeps the discriminated reply instead of widening it, and the settings resolver takes `unknown` -- which is what it really reads, since a persisted profile can hold a value no version of this code wrote. * fix(ai-vault-search): let a refreshed scan root reach the live index The parent re-resolves scan roots before every policy push, precisely so a WSL distro or extra Codex home that appeared since the child spawned enters the window. The child forwarded only the settings to a live instance and used the roots solely in its `??=` initializer, so those roots were dropped for the child's lifetime. The indexer stays immutable: a structurally different root set closes the pair and constructs a new one, the same way a changed databasePath already does. Compare via `sameSessionSearchRoots` rather than a plain JSON compare, because nothing fixes the key order two producers write; lists are sorted too, since the indexer walks every root and a re-enumeration that reorders is not a change. An unchanged set still never restarts a running index. The orcad and relay in-process hosts resolve roots once at install and never re-apply, so they have no such seam. * fix(ai-vault): restart the scanner child the index is holding Three review items. The hold keeps a child alive for the index, but only a queued call ever started one: `pump()` skipped a hold with an empty queue, so an idle indexing child that crashed, or an `ensureChild()` that failed at start, left indexing stopped until an unrelated request happened to arrive. `pump()` now starts the child the hold requires, which is also the restart callback the fault policy already schedules, so the existing delay and circuit bound the retry exactly as they bound a queued call's start. `updateSessionSearch` goes through the same seam instead of its own `ensureChild` call. A search registers no AbortController, so a cancel sent for a search id was added to the `cancelled` set and never consumed. Nothing can reach that today -- no caller passes a signal and the child answers in milliseconds -- so this is only a leak of ids: consume it when the search settles. The orcad argument doc claimed a `--`-prefixed value stays a flag. The parser takes the next token regardless, and orcad-launch-contract.test.ts pins that, so the doc is what was wrong. Behaviour is unchanged. * fix(ai-vault): recover search indexing and refresh scan roots * fix(ai-vault): defer search refresh policy reads * fix(session-search): stabilize paging and host enablement * fix(session-search): refresh host roots within full sweeps * docs(session-search): clarify initial root fallback * fix(native-chat): hide activity while awaiting input (#20496) * fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds --------- Co-authored-by: Merge Sim <sim@local> * Update README downloads badge * Bump mobile app.json to 0.0.50 (#20661) * fix(native-chat): bound a dispatch reason before it reaches the journal row (#20654) `AgentJournalSubmission.reason` was the only unbounded field written by Orca's own code. `dispatchSafely` sets it from the adapter's raw `error.message` and `journalDispatchRowBuilder` stored it verbatim, so a provider error carrying a multi-megabyte body -- a stringified HTTP error payload, say -- reached the row at whatever length the provider sent, and stayed on disk at that size for the life of the journal. It now goes through `boundInlineText` with the journal's existing inline limit, the same idiom already applied to arbitrary text on the Claude and Codex translation paths. The bound must stay head-preserving. `dispatchRejectionWasTransportWriteFailure` prefix-matches the value, and `dispatchRejectionReasonIsInternal` builds on it, so a bound that kept the tail instead would stop classifying a clipped transport failure and render raw provider text to the user as an ordinary rejection notice. A test pins that, and clipping stays marked rather than silent so a truncated reason is never presented as the provider's complete explanation. Rows written before this keep their full text, so readers can still meet an unbounded reason. * fix(native-chat): keep a resumed transcript pinned to its end (#20651) * fix(native-chat): keep a resumed transcript pinned to its end Follow state was recomputed from distance on every scroll event, and a pin writes scrollTop itself, so the browser reports that write back as a scroll event a frame later. Once a resumed session's later history pages and settling row heights had moved the end away from it, that echoed event read as the reader leaving and the pin was dropped for good, stranding them mid-transcript. Measured in Chromium: a pin followed by same-task growth delivers a scroll event reading 2000px from the bottom, indistinguishable from a reader scrolling up. Pins now go through the virtualizer instead of writing scrollTop directly, so both parties resolve the end through the same maximum rather than holding rival definitions of it. Whether the reader left is now a question of provenance rather than distance: an offset this transcript wrote is never a departure. The end test reads live geometry, because the virtualizer's own isAtEnd subtracts a cached offset from a live maximum and this handler runs before that cache is refreshed. overflow-anchor:none stops the engine moving scrollTop under a settling row, which would otherwise look like the reader. This does not make ownership singular. The virtualizer still writes autonomously from several paths and those writes stay unattributed; what this removes is the rival definition of the end, not the second writer. * fix(native-chat): cancel stale end reconciliation * fix(native-chat): attribute scroll ownership centrally * Surface stage, unstage and discard failures with retry capability (#20423) * fix(source-control): surface stage, unstage and discard failures * fix(source-control): use single slot for entry failure toasts - Consolidate entry failures to one stable slot instead of per-worktree - Handle stale retries inline at click time rather than via a cleanup hook - Remove retry button from discard failures to prevent destructive accidents * test: improve type safety and mock patterns in source-control tests - Add proper type definitions for toast options and test data instead of using `as never` - Replace `mock.calls.at(-1)` with safer `mock.lastCall` pattern - Create `entry()` helper to construct typed test entries - Add explicit type annotations to mocked functions for better IDE support * test: extract shared toast options type for source control tests Consolidate duplicate `ToastOptions` type definitions across three test files into a single `SourceControlToastTestOptions` type, reducing duplication and improving consistency. * fix(source-control): separate refresh failures from mutation failures Post-mutation refresh failures are logged separately, not surfaced as toasts (mutation already succeeded). Use preventDefault() on retry to prevent sonner's auto-dismiss from swallowing re-raised failures. Consolidate stage/unstage into a shared handler to reduce duplication. * fix(source-control): only dismiss entry failures from the owning worktre Track which worktree owns the shared entry-failure toast slot. When a mutation completes, only dismiss the slot if the completing worktree is the one that raised the failure — a slow retry in one worktree should not erase a failure another worktree has since raised into the slot. * Remove entry mutation status refresh helper Inlined into the caller during consolidation of failure handling and tracking in the source-control entry mutations flow. * Simplify entry mutation refresh without wrapper Call refreshActiveGitStatusAfterMutation directly instead of through the refreshEntryMutationStatus helper. This ensures refresh failures propagate directly from the callback without being caught as mutation failures. Remove tests that validated the wrapper's error handling. * Add copy button to code blocks (#20357) * feat(native-chat): add copy button to code blocks Enable users to copy code snippets directly from chat messages via a dedicated copy button on fenced code blocks. Supports language detection and integrates with markdown rendering via a `renderCodeBlock` prop. * i18n: add English copy code button label * refactor: use React.isValidElement type parameters for type narrowing - Specify props types as type parameters to React.isValidElement instead of casting after the fact - Allows TypeScript to narrow element.props type automatically - Eliminates manual type assertions in extractCodeText and extractCodeFenceLanguage * fix(source-control): prevent text wrapping in section headers and action buttons (#20046) * fix(source-control): prevent text wrapping in section headers and action Use flex layout constraints (flex-1, shrink-0) and text truncation instead of wrapping to keep section labels and action buttons on a single line in the right sidebar. * test(source-control): add section action button alignment tests Ensure View all button stays on single line with icon actions in crowded section headers. Pin layout constraints (shrink-0, flex-wrap, whitespace-nowrap) to prevent regression. * Rely on Button base styles for action label wrapping Remove redundant shrink-0 and whitespace-nowrap utilities from section action buttons. These should be supplied by the Button component's base variant, not duplicated at each usage site. * fix(store): stop two no-op writes from re-running every selector in the app (#20641) * fix(store): stop two no-op writes from re-running every selector in the app zustand bails out of a `set` only when `Object.is(next, state)`. Two updaters that mean "nothing changed" hand it a fresh reference instead: - `setWorkspacePortScanRefreshing` wrote unconditionally — the one action in its file that did; its four siblings all early-return `state`. - `applyGitHubPRRefreshEvent` ended its no-op branch with `: {}`, and `Object.assign({}, state, {})` reproduces every field unchanged while still notifying. ~20 sibling sites in the same store already use `return state`. Both rebuild the root and wake every subscribed selector (~2.2k per the listener census). Renders are unaffected — the selection is unchanged — so the cost is wasted selector evaluation, not commit pressure. The resulting state looks identical either way, which is why it goes unnoticed; both tests therefore count subscriber notifications rather than asserting state. * test(store): use checked initial state in notification regression --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <neil@stably.ai> * fix(ai-vault): bound streamed remote JSONL records (#20700) * fix(ci): stop hourly versions dropping below a tagged or already-shipped build (#20699) * fix(ci): stop hourly versions dropping below a tagged or already-shipped build Hourly/daily/adhoc based their X.Y.Z on GitHub releases, not git tags. When v1.4.202 was tagged and then its GitHub release vanished, the next hourlies shipped as 1.4.202-hourly — below both stable 1.4.202 and the 1.4.203-hourly builds already installed, so electron-updater stopped offering updates. Read main's v* tags and already-published channel tags instead. * docs(ci): record that 1.4.202's release was unpublished for a bug The leftover tag is what hourly must still honor; this was not a failed cut. * fix(store): preserve state identity for no-op updater branches (#20703) * fix(codex): settle a structured send on admission, and stop minting a colliding identity (#20138) * fix(codex): settle a structured send on admission, and stop minting a colliding identity Two sends could be written into the journal under one durable identity. Codex coalesces a mid-turn `turn/start` into the running turn rather than refusing it -- measured against real `codex app-server` builds 0.147.0, 0.150.1 and 0.153.4, none of which refuse and none of which fire a second `turn/started`. The dispatch path read the turn id from the turn/start response and stamped every accepted send `ordinal: 0`. Since a coalesced send gets the running turn's id back, two submissions persisted the same `providerItemId`. That string is durable, and it is the key a restore uses to match a submission against provider history, so the second message's real history row matched nothing and rendered as an extra bubble on replay. On 0.147.0 it is worse than a collision: the coalesced response returns a turn id that never starts and never completes, so the persisted key named a turn absent from history and NEITHER message could match. Identity is now minted from the echoed user message at `identityFor` -- the single point that mints the journal row's own identity -- so the settled key is by construction the one replay computes, rather than a parallel calculation that can drift. Dispatch returns `admitted` when the transport write completes; identity settles on the echo through a channel that did not previously exist for Codex. Waiters are keyed by client message id instead of being shifted off the front of an array by arrival order, and they are cleared on session close and child exit -- previously a timeout was the only thing that ever ended one. `TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured: `readCodexTurnId` returns non-null on all three, so the 10s wait never fired. The comment justifying it claimed older builds acknowledge before the id exists, which no tested build does. Three comments asserting Codex answers a mid-turn send with `turn already running` are corrected. Their only backing was a test fixture inventing that error string. The correction is factual only -- every changed line in `src/main/runtime/orchestration/` is a comment, and mid-turn delivery is still refused for both providers. Whether that policy is right is a separate question; it was resting on a false premise. Known gap, stated rather than implied: this prevents new collisions and does not repair journals already written with a colliding or phantom key. Those conversations keep duplicating on restore. Repairing them means re-matching persisted submissions against provider history and rewriting `providerItemId` -- which is what `journal-submission-reconciler.ts` is written for, and it still has no production caller. * test(codex): drop the synchronous-accept contract and the colliding `:0` from the integration fakes Three tests in the structured-session integration suites encoded the dispatch contract this branch replaces, and two of them pinned the defect it fixes. They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying `providerItemId: codex:<thread>:<turn>:0` at send time. That ordinal was never observed; it was stamped on every accepted send, which is exactly the collision this branch removes -- a send coalesced into a running turn is answered with the running turn's id, so two submissions persisted one durable key. The visible failure was a 30s timeout rather than a failed assertion. The fake client advertised no `agent-session.pending-send-result.v1`, and without it the host holds the reply until the send settles: a shim for clients too old to render a pending bubble. The fake provider then echoed the user message with no `clientId`, so nothing could correlate that echo back to the submission, and the wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and the fake now does too, which is what makes it a model of the provider rather than a sketch of one. The identity assertion is kept rather than dropped. Each send now asserts `pending` with no identity at admission, then asserts the submission settles `accepted` at `codex:<thread>:<turn>:0` once the echo lands. Same ordinal, but earned from `identityFor` on the echo -- the key a replay recomputes -- instead of guessed from the turn/start response. Ablated: removing `clientId` from the two echoes leaves both submissions `pending` and fails both assertions, so the assertion is load-bearing and not satisfied by something incidental. Both suites' client fixtures now advertise the capability set the desktop renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by a client. The older-client settlement wait keeps its own coverage in `src/main/runtime/rpc/methods/structured-agent-session.test.ts`. `structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same reason; it drives the host directly, so it never took the compatibility path, and what proves delivery there is still the turn the reacquired provider starts. The replay suite's "without dispatching it twice" property is untouched: one `turn/start` call, one replayed ledger row. * fix(codex): preserve unsettled dispatch correlations * test(codex): type the dispatch fixtures instead of asserting over them main's new casting gate (#20367 base) flags type assertions on changed lines. Replace them with checked types: the recording sink already satisfies its interface, both CodexSession fixtures are now annotated and carry real collaborators, the settlement assertion compares whole identities, and the integration helper reads submissions through the host's public journalSnapshot instead of its private session map. * fix(test): merge the duplicate doubt-reasons import the merge left behind Both sides added an import from journal-dispatch-doubt-reasons and the merge kept both statements, which the whole-repo native plugin gate refuses under --deny-warnings. * test(codex): a Fast mode turn is admitted, not accepted #20506 landed its Fast mode tests against the dispatch contract this branch replaces: a Codex send now returns admitted and settles its identity on the provider echo. The tier assertions the test exists for are untouched. --------- Co-authored-by: Merge Sim <sim@local> * test: add a verified OMP native-chat mock scenario (#20655) Co-authored-by: plotarmordev <plotarmordev@users.noreply.github.com> * fix(omp): preserve status after terminal title owner rewrite (#20610) Validated and independently reviewed OMP integration fix. Co-authored-by: shahidbeig-a11y <258701601+shahidbeig-a11y@users.noreply.github.com> * fix(omp): preserve zsh startup with global aliases (#20621) Validated and independently reviewed OMP integration fix. * fix(git): avoid Windows tree kills after the command has exited (#20606) Validated and independently reviewed OMP integration fix. * fix(terminal): retain renames before renderer pane hydration (#20619) * fix(terminal): retain renames before renderer pane hydration * test(terminal): keep late renames from recreating closed tabs * fix(agents): find OMP by its full project name (#20647) * fix(agents): find OMP by its full project name * test(agents): make picker baseline proof omit OMP aliases * style(test): brace picker baseline condition * feat(runtime): stream file uploads instead of buffering whole files (#16106) * feat(runtime): stream file uploads instead of buffering whole files Staging read each dropped file whole with readFile(), base64-encoded it (a 4/3 expansion), and passed the string through IPC to the renderer, which re-chunked it. Peak memory was ~2.3x the file size before a byte moved, so a 25 MB per-file cap existed to protect the heap. Staging now records identity only. The byte pump moves into main, where the file handle and the runtime socket both live: 384 KiB slices (512 KiB once base64-encoded, matching the chunk size the renderer used) appended through the existing files.writeBase64Chunk RPC. Peak memory is one slice regardless of file size, so the ceilings become user-safety limits on an unattended transfer — 2 GB per file, 8 GB per drop — and over-limit errors name both the size and the limit. Because staging and streaming are separate calls, the staged entry carries size, inode, device and mtime, and the streamer re-checks all four against the pre-open lstat and against the handle it actually reads. A source replaced or rewritten at the same size between the two calls is refused rather than uploaded under the original name. The post-read check compares mtime as well as size, so an in-place rewrite mid-transfer aborts before commitUpload renames anything into place. O_NOFOLLOW, realpath containment and stat identity are preserved, and the pairing revision plus the runtime id ride every chunk, so a re-pair or a replacement runtime aborts instead of appending the rest of the file to a different host. No wire change: files.writeBase64Chunk and its params are untouched, so old and new hosts behave identically. The SSH import path is separate and unchanged. The web client has no local filesystem to stream from and says so instead of failing obscurely. * fix(runtime): close the empty-upload and per-drop budget holes Two gaps the first pass left open. A zero-byte source returned before the post-transfer identity check, so a file that gained content during the empty write's round trip committed as an empty file at the user's chosen name. The empty chunk now falls through to the same final check the slice loop uses. Each staged source also started its own byte counter, so the 8 GB ceiling capped one source rather than the drop: five 2 GB files staged cleanly at 10 GB total. The IPC handler now carries one budget across sourcePaths and adds only what each source actually staged. The per-file ceiling is still re-enforced where the bytes move; the drop total holds at staging because identity enforcement means each file streams exactly the bytes measured. * docs(runtime): name the invariants the upload helpers carry * fix(runtime): name the source in errors and stop uploads with their window Three problems an independent review turned up. A dropped file's relative path is '', so the over-limit error read "'' is 3 GB, over the 2 GB per-file remote import limit" — the message this change exists to fix, naming nothing. Errors now fall back to the file's own name; the staged entry keeps '' so the destination path is unaffected. The streamer had the same shape, falling back to the hidden .orca-upload-<nonce> temp destination, a path the user never chose. The byte loop used to live in the renderer and died with it. Moving it into main meant closing or reloading the window left the rest of a multi-GB transfer running, with the renderer's temp cleanup never reaching its finally. An AbortSignal now rides the caller's lifetime and every chunk, is re-checked per slice, and main sweeps the abandoned temp path itself when the renderer is no longer there to do it. Upload failures also reached the import result wrapped in Electron's "Error invoking remote method '...'" prefix, because the throw crossed IPC instead of happening in-renderer; extractIpcErrorMessage unwraps it. An existing staging test asserted the empty-name message, so it encoded the bug rather than catching it; it now asserts the file name. * test(runtime): cover the containment check and the per-chunk host guards The "escapes the dropped root" test only reached the lstat symlink guard, so assertEntryInsideRoot had no coverage at all. The shape that actually needs it is a regular file under a symlinked intermediate directory: lstat sees a plain file, and realpath containment is the only thing that refuses it. Disabling the guard now fails this test and nothing else. Nothing asserted that the SSH target, connection generation and execution host reach the writeBase64Chunk params either — the renderer tests stop at the IPC boundary, so the streamer's half of that contract was untested. * fix(runtime): survive a straggling append when sweeping an aborted upload Aborting rejects the in-flight chunk locally, but the host may still apply that append, and appends open with flag 'a' — which recreates the file the sweep just deleted. The delete and the straggler also race: they are separate calls on a queue that is not ordered between them. Slices are strictly sequential, so at most one append can be outstanding. A second pass after it has had time to land is therefore sufficient, not merely a heuristic. The sweep moves out of filesystem-mutations.ts into its own module so the behaviour is testable directly. Found by an independent review pass, which also pointed out that the "escapes the dropped root" test only reached the lstat symlink guard. * fix(runtime): abort uploads only when the document commits, and honour manual disconnect per chunk did-start-navigation fires before will-navigate blocks an external link or a stray file drop, and the renderer survives those (verified against Electron 43 with a hidden window). Aborting there killed a healthy upload with a misleading 'window went away' error. did-navigate fires only once a new document has replaced the caller. The renderer's per-chunk calls used to go through the IPC handler that refuses a manually disconnected environment; the loop in main made no such check, so a disconnect mid-upload kept pushing the rest of the file. The handler now resolves the selector to an environment id and the streamer checks it per slice. Adds slice-boundary coverage against the real chunk schema and host write flags, staging-to-stream on a real filesystem, and handler-level lifetime tests. --------- Co-authored-by: Neil <neil@stably.ai> * fix(omp): preserve saved conversation names in session history (#20636) Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(ui): contain idle caret paint so agent panes stop burning CPU (#10554) An idle agent pane kept ~40% of a core busy just by being frontmost. The xterm cursor and the native chat caret blink with no paint-containment boundary, so Chromium treated each blink as damage to the whole pane ancestry and re-rasterized it twice a second. - `.xterm-container` and the native composer's input shell get `contain: paint`, bounding blink damage to the surface that blinks. - The mention hint gains `z-20` to match the slash picker: a contained element becomes a stacking context and paints at z-index 0 in tree order, which would otherwise cover the hint's drop shadow. Also records that DECSCUSR pins `decPrivateModes.cursorBlink`, which wins over the option in `_updateCursorBlink` — so parking `cursorBlink` does not reliably stop a hidden pane blinking. Pre-existing, documented only. Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com> * feat(relay): expose preloaded PostgreSQL statement statistics (#20712) * fix(runtime): apply the tui-idle evidence ranking to mailbox delivery (#20578) fix(runtime): retry a delivery that the idle gate refused Gates delivery at the two points where each implementation commits to typing into the pane, rather than at each caller, and parks-and-re-offers a refusal so late idle evidence cannot strand a queued message. Refs #6011 * fix(agent-session): honour the backup-recovery fence floor on surface release (#20708) * Fix surface release fence recovery floor * fix(agents): advance backup recovery floor past lost mint * fix(hooks): actually terminate a timed-out hook's process tree (#20576) Repairs #20559, whose termination was a no-op: `detached` is a spawn-only option and `exec` ignored it, so the shell never became a group leader. Verified against real processes. Refs #19334 * fix(worktrees): safely remove prunable git-file registrations (#20617) Preserve checkout files and the named branch when removing a positively attested malformed Git-file registration. Reject file/symlink targets in deferred directory deletion. Verified exact head with 75 focused tests including actual Git malformation, preserved marker/file bytes and branch HEAD. Independent review and complete product CI passed. WSL routing is covered by unit tests; direct SSH fails safely without local recovery. Fixes #17316 * fix: keep OMP terminals when folder workspaces become Git repos (#20653) Preserve the original folder locator through Git upgrade and subsequent listing, persistence, and removal decisions after proving it still names the same checkout. Independently reviewed with 60 focused persistence/listing/removal tests and six native Windows real-Git/NTFS cases covering case/slashes, junction retention and retargeting, remote-host isolation and unrelated checkout preservation. Prior source-connected native OMP proof confirms process survival. Full PR CI passed; no rebuilt full-app after-proof claimed. * fix(native-chat): cancel pending prompts precisely (#20601) * fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds * fix(native-chat): cancel pending prompts precisely * fix(native-chat): consolidate capability imports * fix(native-chat): harden precise prompt cancellation * fix claude cancellation teardown races * retry claude prompt lifecycle admission * bound claude prompt cancellation retry work * fix(codex): bound prompt turn identity on registration * fix(native-chat): route rejected late dispatch settlements * fix(codex): retain exact cancellable prompt turn ids --------- Co-authored-by: Merge Sim <sim@local> * fix(omp): resume independent child sessions from history (#20629) Add Resume to eligible local OMP child history rows. Resolve lazy child targets from their own cwd and host, never an unrelated active workspace. Unresolved folder-only targets stay disabled; copy-command remains available. Verified production map/resume resolver regression before/after; 50 focused tests and independent 40-test review, web types and code quality passed. Actual OMP storage/CLI smoke confirms distinct child/grandchild sessions. No native Windows or live SSH launch claim. Addresses #12885 Scope 1. * fix(ai-vault): expand nested OMP session history (#20663) Expand saved OMP descendants lazily while preserving exact child targets for Resume and View Log. Retain expanded branches across virtual scrolling and reject late responses/cycles. Includes the independently reviewed child-workspace correction from #20629. 61 combined target/map/nesting tests and actual OMP child/grandchild storage/CLI smoke pass. Earlier hidden Electron proof covers eight generations and narrow sidebar layout. Folder-only unresolved child targets remain disabled. No live delegation or full terminal-launch proof claimed. Addresses #12885 Scope 2. * fix(composer): clarify failed attachment drops (#20704) * refactor(renderer): give the IPC error reader a clamped and an unclamped shape * fix(composer): name the attachments a drop could not add, in one toast * fix(composer, source-control): use one stable failure toast slot - Replace per-worktree toast IDs with single slot that replaces on each failure - Remove destructive retry actions; discard must confirm in dialog - Consolidate filesystem import types to shared location - Add compactIpcErrorMessage for string error handling * refactor: centralize filesystem import types and clarify failure naming Move import result types from main/ipc to shared layer so they're available across preload and renderer. Rename uniformFailure → commonFailure and skippedOrFailed → failureCount for clarity. Simplify preload/API type definitions by reusing shared types directly instead of duplicating inlined union shapes. * Reuse single toast slot for composer drop failures Multiple drop failures now replace the previous toast instead of stacking, preventing notification clutter. Uses a dedicated toast ID separate from Source Control's stage/discard notifications. * fix(deps): migrate Tiptap security updates with Markdown compatibility guards (#19376) * chore(deps): evaluate coordinated Tiptap security migration * fix(editor): adapt link ranking and initialization for Tiptap 3.31 * fix(editor): preserve literal Markdown through Tiptap serialization * test(editor): cover literal saves in local folder and paired workspaces * test(editor): reselect folder after closing its final tab * perf(editor): avoid repeated inline source-marker lookahead scans * refactor(editor): inline redundant HTML match wrapper * test(chat): await Tiptap React skill-pill rendering --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: m4air <m4air@Mac.localdomain> * Distinguish pane load failures from empty states (#20735) * refactor(renderer): give the IPC error reader a clamped and an unclamped shape * fix(settings): tell a failed load apart from a genuinely empty pane * refactor: consolidate import types and simplify failure handling - Move filesystem import types to shared for renderer use - Add compactIpcErrorMessage for single-line error display - Consolidate entry failure toasts to single global slot - Simplify account tracking and discard retry logic * fix type * fix: clear stale state when pane loads fail Credential reads, account fetches, and skill scans can fail, leaving stale data on screen. This change clears previous state when a load fails, distinguishing load failures from genuinely empty results, and prevents stale controls from appearing after failed re-checks. Use readIpcErrorMessage for consistent error handling and track runtime targets to invalidate results from old targets. * fix(settings): show credential action when bitbucket status read fails When the credential-read operation fails, allow users to retry by showing "Add or replace credentials" button. Initialize the credentials dialog with the current (confirmed) connection state instead of stale data from a failed read, preventing outdated information from pre-populating the form. * Log bounded PostgreSQL acquisition and execution failure diagnostics (#20749) * test(mobile): pin each RPC golden to the recorder inputs that can reach it, not the whole directory (#20662) * refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's `recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its merge with main conflicted on that one line in 153 files; every future domain PR would collide with every other in flight the same way. Split the directory at a real seam instead of a filename convention: `adapters/` holds one module per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers the engine only. A new `adapterSha256` covers the source of the module that mounts each operation a golden's scenarios drive, read off the same `mounts` calls that build the table the recording runs against, so the pin cannot name a file the runner did not use. Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file, and an adapter importing a sibling each fail. The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new header field; the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the split recorder/adapter digest Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers `adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and recording ran against the same pinned product tree. git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l 0 The seven `adapterSha256` values partition the 153 goldens by the module each was recorded through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory, 9 tasks, 8 workspace settings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop pinning goldens to recorder inputs no recording can read The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the per-family mutant registry beside it, and the probe-hole witness. None can change a recording -- the loader consults a mutant only when a mutant test asks for one, and no suite but the two recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have and charged every domain a full re-record for it. `mutants/` now holds the table, the registry, the reference states, the mutant suites and the probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts` checks exactly that, and fails if an engine file names the directory or anything outside imports from it. `recorderSha256` also pins only the suites in `recording-drivers.ts`, which `scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or writes one to a scratch directory, is no longer provenance for a recorded file. `OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden. Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to `root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed anything. Each call now spells the root differently. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the mutant and driver exclusions Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153 because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that module now carries its own exposure declaration. git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256)":' | wc -l 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the preferences actions the merge resolution dropped #20568 added `resume` and `trust` actions to the `settings.task-preferences` adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the `pilot-mount-adapters.ts` conflict in favour of the registry merge silently discarded them and `tw-task-preferences-resume-write` failed to record at all ("Missing or completed request: ui.set#1"). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens at main's tip after the merge All 208 goldens, header-only. `baseline` moves from50e752fc66to main's tipc6a7216984, `goldenFormatVersion` from 4 to 5, `recorderSha256` to the value of the engine with `adapters/` and `mutants/` carved out, and `adapterSha256` is new on every file. Nine distinct adapter digests over 208 goldens: each golden now pins only the module that mounts it. No observation moved. The whole-diff census against origin/main reports exactly four changed keys and nothing else: 208 "adapterSha256": 416 "baseline": 416 "goldenFormatVersion": 416 "recorderSha256": Recorded in place rather than through the README's detached-baseline dance: this branch changes no product file, so its tree at the merge is byte-identical toc6a7216984under mobile/src, src/shared and the lockfile, and the parity claim stays non-circular. README says so now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the recording drivers to the engine's mutant-seam rule The name scan exempted every `.test.ts` on the ground that a test cannot change a recording. Two of them can: the recording drivers are the recording path. A driver that read the mutant table by path rather than importing it passed both seam checks — the import scan sees no import, and the name scan waved it through as a test: const table = resolve(import.meta.dirname, 'mutants/operation-mutations.ts') console.log(readFileSync(table, 'utf8').length) at the top of `pilot-recordings.test.ts` gave 2 passed before, and after this change fails with ["pilot-recordings.test.ts"]. Only non-driver tests are exempt now. This file lives in `mutants/`, which `recorderSha256` skips, so no golden moves: the recorder suite is green on the existing 208 with zero dirty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the registry parameter no caller varies `pilotMountAdapters` took `registered` so a caller could mount a different module set; all six callers take the default. The header-digest tests vary the registry through `goldenRecording`, which keeps its own parameter and is where the stub roots need it. Engine source, so `recorderSha256` moves and the goldens follow in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens after the registry parameter came out All 208, `recorderSha256` only. The re-record against the previous commit moves 416 lines, every one of them that field: 416 "recorderSha256": Against origin/main the picture is unchanged from the merge: 208 goldens, 0 added or deleted, 0 non-header lines, and exactly four keys differing — 208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256" Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): wrap the recording README at the width the rest of it uses Seven lines this branch added ran past 100 columns, worst 124. No wording changed. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the worktree overlay, not the archive that cannot work `git archive` was offered alongside a detached checkout as a way to lay this branch's recorder over the pinned baseline. It cannot work: the fence in scripts/rpc-recording.mts runs `git diff --quiet <baseline>` and an untracked-file check, both of which need a real `.git`. In an archive tree git exits non-zero for lack of a repository and the script reports "Product sources or lockfile differ from the pinned main baseline", which reads as a product mismatch that is not there. The transport agent lost time to exactly that. Names `git worktree add --detach` only, and says what the misleading failure looks like if someone tries an archive anyway. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close two ways an adapter module escapes its own digest Two holes, one class: the seam was checked by how an import was spelled and by what the register's values evaluated to, never by where they resolve or where they were written. Inward imports: the scan dropped every specifier starting with `..`, so `'../adapters/settings-mount-adapters'` climbed out of the directory and back into it unseen. A reviewer had `new-tab-agent-mount-adapters.ts` project a value read from the settings module, edited that module, and watched the mounted state change while the new-tab adapter digest held. Specifiers now resolve against the directory and anything landing back inside it fails: ["new-tab-agent-mount-adapters.ts imports ../adapters/settings-mount-adapters"] The register: `adapters/mounted-operation-modules.ts` is pinned by nothing — `recorderSha256` skips the directory and `adapterSha256` reads each entry's `source`. An `exposes` written inline there drives the mounted product module with no digest covering it. The same reviewer replaced the new-tab entry's `exposes` with a literal overriding `loadMobileNewTabAgentOptions`; twelve fence tests passed. Both `mounts` and `exposes` must now be identifiers the register imports from that entry's own module: ["new-tab-agent-mount-adapters.ts writes exposes inline instead of importing it"] Checked on the register's syntax, not its values, because an inline literal and an imported binding are indistinguishable once evaluated. Pinning the register in the engine digest would also close it, and is the wrong trade: every domain adding a register line would re-digest all 208 goldens, which is the conflict this PR exists to remove. Keeping the register an index costs nothing and keeps a domain's line local. Both fixes live in a `.test.ts` outside the drivers, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the mutant seam from the drivers out, not by spelling The seam rested on a grep for the literal `mutants`, which the exported `MUTANT_DIRECTORY` spells without containing. A reviewer had `pilot-mount-adapters.ts` read the mutant table through that constant and both checks passed. The README's claim — that nothing on the recording path names the directory — was false as written. Three changes, in order of strength: Reachability is now proved forward. The suite walks the static import graph from the two recording drivers and fails if any module under `mutants/` is in it. That answers the real question, what a golden's bytes can depend on, instead of the old inward scan's question, who mentions this directory. Non-emptiness is asserted on both sides so a graph that resolved nothing cannot pass by reaching nothing. The name scan covers both spellings, for paths a module can be read by rather than imported. The reviewer's probe now fails as ["pilot-mount-adapters.ts"]. `MUTANT_DIRECTORY` is no longer exported. Its two consumers were both tests of the digest, and they now spell the path instead, which is strictly better for them: a test that imports the constant follows a rename silently, while one that spells it fails on a rename — and that specific directory name is the whole soundness argument. This edits `recorder-digest.ts`, so the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens after MUTANT_DIRECTORY stopped being exported All 208, `recorderSha256` only. Against the previous commit the diff is 416 lines and every one of them is that field: 416 "recorderSha256": Against origin/main, unchanged: 208 goldens, 0 added or deleted, 0 non-header lines, four keys differing — 208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256" Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the mutant seam's actual argument, and its edge The README claimed nothing on the recording path names `mutants/`. That was the old inward scan's claim and a reviewer falsified it with the exported constant. It now describes what the check does: a forward walk of the import graph from the two recording drivers, plus a name scan in both spellings for read-by-path, plus the constant no longer being exported. It also names the case neither closes — a path assembled from fragments at runtime. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the engine/adapter seam in both directions The inward scan only held adapters to the seam. An engine file importing an adapter executes code its own digest skips and that every golden recorded through another domain leaves out of `adapterSha256`, so the register is now the only crossing allowed from the engine side. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name what the driver walk missed instead of counting it Seeding `seen` with the drivers made the driver-presence check true by construction, and the size bound compared a graph inflated by `typeof import` product modules against a recorder-sized number. Both go; the walk now reports the recording files it failed to reach, which is empty today and names an orphan engine file the moment one appears. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): reflow four paragraphs left ragged by the rewrap Orphan fragments only, no wording change: the golden-schema field list, the mutant-evidence paragraph, the probe-witness sentence and the re-anchor note. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * Fix PTY child process verdict to preserve unverifiable state (#20729) * fix(pty): preserve unverifiable local child reads * fix(pty): make child-process inspection synchronous Separate foreground and child-process sampling. Sample child processes synchronously after confirming foreground availability, returning unverifiable verdicts when pty reads fail. Handle both transport loss and local read failures uniformly in the completion coordinator. * ci: keep the baseline build off the compatibility matrix lanes (#20733) The compatibility gate started the pinned 2.25.5 source build inside the same step that runs the three measured lanes, so `make -j$(nproc)` competed with two container lanes whose wall clock is container starts, not Git. A boundary case that costs ~1.5s stretched past Vitest's 30s timeout and failed the job. Build the binary in its own step before the matrix, and pull both images before any lane starts so a lazy pull cannot stall whichever test its sibling is timing. * fix(ci): stop defining pilot mutant tests inside a conditional (#20755) `vitest/no-conditional-tests` fires on the `if (mutation) { it(...) }` inside the pilot loop, and `audit:code-quality:native` runs oxlint with `--deny-warnings`, so main's "Enforce focused code-quality plugins" step exits 1 and blocks every open PR. Pair each pilot with its pinned mutant and reference state before the loops, so every iteration defines exactly one test unconditionally. Same 14 tests, same names: 11 mutant-kill tests and the 3 reference tests that `skipIf` still gates on RPC_FOUNDATION_REFERENCE_ROOT. * fix(native-chat): let a reader park just above the latest message (#20709) * fix(native-chat): let a reader park just above the latest message A reader who scrolled up by less than the bottom threshold was still classified as being at the end, so follow stayed armed and the next chunk of stream carried them back down. One constant was answering two different questions: how close to the end still counts as pinned, and whether a reader's own scroll meant to stay there. The first wants slack, because a streaming last message jitters in height by tens of pixels. The second wants almost none, because it is a statement of intent. Give it its own, far stricter band, and move the choice of band into the decision rather than leaving it to the call site, which is where the two got conflated. Re-arming follow now requires the reader to be within 4px of the end: enough for fractional-pixel and zoom rounding, well inside one line of prose. The pin and the jump-to-latest affordance keep their 48px band. * fix(native-chat): make transcript intent own end following * feat(design-system): gate renderer UI with @shadcn/lint (#20731) * feat(design-system): gate renderer UI with @shadcn/lint Wires shadcn-ui/lint's Oxlint plugin into the two places this repo already ratchets: the changed-lines PR gate for rules the renderer can't satisfy today, and `pnpm lint` for the one that is already at zero. - config/oxlint-design-system.json: no-restyle (layout allowed), no-raw-colors, require-static-classes -- scoped to src/renderer/**/*.tsx, run over added lines only. Measured at 10 findings across the last 60 commits (771 changed files), so it holds the line without a migration. - config/oxlint-dead-classes.json: no-unknown-classes repo-wide, with the renderer's plain-CSS hook namespaces allow-listed. Now at zero. - no-inline-styles and no-arbitrary-values stay off; STYLEGUIDE says why. Fixes the three live bugs the linter found: - `--editor-surface` never reached `@theme inline`, so `bg-editor-surface` generated no CSS -- 12 editor/artifact/notebook panes fell through to the page background instead of #1e1e1e in dark mode. - `scrollbar-none` is not a Tailwind utility and was declared nowhere, so the remote file browser breadcrumbs showed the scrollbar they meant to hide. Declared as a real `@utility`. - Notebook markdown cells used `markdown-preview-body`, which no stylesheet defines; the styled class is `markdown-body`. They rendered unstyled. * ci: run the dead-class gate in PR CI `pnpm lint` gained check:dead-classes, and pr-workflow-lint-parity requires every `pnpm lint` step to have a matching step in pr.yml. * fix(notebook): keep markdown theme selectors working * Update README downloads badge * fix(native-chat): preserve detached transcript position during growth (#20710) * fix(native-chat): stop the transcript following an end it measured short The virtualizer compensates a row's measured size change by moving scrollTop whenever it believes the view was already at the end. It decides that from the spacer's own height minus a container-absolute offset, so the distance it computes is short by everything in the document outside the spacer: the transcript's top gutter, the "load earlier" block while older history is still pageable, and the trailing chrome. A reader sitting ~100px above the bottom therefore measured as "at the end", and every row that settled below them dragged them down to it. Measured in the windowing harness with a 92px gutter and 24px of trailing chrome: a reader parked 96px above the end is pulled to the end on the first growth frame, scrollTop 9261 to 9357. The same option gates following an append, but that path measures the true document distance, so it was never wrong, only redundant. The transcript already decides whether to follow the end from the scroll container's real geometry, and it re-pins once the growth is in the document rather than before it, where the library's own write is clamped. Both library end behaviours are retired by a threshold no finite distance can meet; the prepend anchoring that shares the option is kept. overflow-anchor:none is restated as structural: the engine's anchoring writes never pass through the scrollToFn adapter that attributes this pane's own scrolls, so they would arrive unmarked and read as the reader leaving. * fix(native-chat): preserve visible rows on first measurement * feat(native-chat): add a message rail for jumping between your prompts (#20719) * feat(native-chat): add a message rail for jumping between your prompts A vertical rail down the right edge of the transcript, one bar per user message, with the bar for the turn you are reading highlighted once scrolling settles. Hovering the rail opens a panel that previews every prompt and jumps to it on click. Bars are capped at 20 and sampled evenly across the thread, always keeping both ends and the active bar, so the rail stays readable at a glance on a long conversation. The active bar is resolved from virtualizer offsets rather than by scanning rendered rows: the transcript is windowed, so an off-window row has no element to measure. The row at the scroll fold resolves to its owning prompt through turnKey, which is what keeps your own message lit while you read a long reply instead of going dark. Jumps reuse the existing reveal/pin path and scrollMessageToTop, which releases the bottom pin. Scrolling through the virtualizer directly would leave a reader snapped back down by the next streamed token. Ticks cover loaded history only; older prompts gain a bar once "Load earlier messages" pages them in. * fix(native-chat): service a rail jump once and give its pin back The rail borrowed the diff reveal's pin to reach a row the window had left behind, but copied only its state shape, not its consumption. The request was never cleared and the effect depended on `slots`, which is rebuilt on every render, so three things went wrong at once: - every later render re-scrolled to the jumped message, dragging a reader back there for the rest of the pane's life, and forcing the bottom pin off each time; - the standing request outranked `revealedDiff` in the shared pin, so revealing a diff outside the window silently stopped mounting its row; - the pinned row stayed mounted and measured indefinitely. The request now carries a monotonic id, is serviced once, and is released as soon as the scroll is issued, which hands the pin back. The rail's scroll listener had the same churn: it listed `items` in its deps, so a streaming turn tore the listener down and cancelled the pending idle timer on every frame and the highlight never settled. It now subscribes once and re-reads on a key built from the prompt ids. Also: the hover trigger is a real button, because `asChild` discards the primitive's focusable trigger and the panel is the only way to reach these messages; the wheel forwarder honours line and page delta modes rather than treating every delta as pixels; and the e2e panel assertion is exact, since a loose bound passed at 20 rows against 20 ticks. * fix(native-chat): make prompt rail accessible and reuse previews * fix(native-chat): supersede prior navigation when selecting a prompt * fix(worktrees): preserve unverifiable disk witness (#20713) * fix(worktrees): preserve unverifiable disk witness * fix(worktrees): follow gitdir/commondir markers in disk witness The disk witness validates created worktrees by reading the repo's common directory from disk. Previously it only checked for a direct .git directory and returned a status object that conflated different failure modes. Now it properly follows .gitdir and commondir pointer files to locate the true common directory, fixing detection on repos with linked git directories (worktrees, submodules) and WSL scenarios. Error handling is simplified: definitive absence returns undefined, other read failures throw with proper cause chains, eliminating the ambiguous "unverifiable" state that would mask real errors. * fix: validate gitdir marker targets are directories When a .git marker points to a missing or non-directory path, that's unverifiable—not the same as an absent .git file (bare repo). Validate accessibility before reading commondir to catch these errors clearly. * test(native-chat): split the windowing test harness out of the suite (#20773) #20719 grew NativeChatMessageList.windowing.test.tsx to 897 effective lines, past the 800 ceiling for test files, so oxlint fails on main. Moves the shared layout/ResizeObserver stubs into native-chat-windowing-test-harness.tsx. No test was changed, split or dropped: still 5 describes and 23 it() blocks, 29 assertions passing. The stubs' mutable knobs become one exported `layout` object because an imported binding cannot be reassigned across modules. AGENTS.md forbids a max-lines disable, so extraction is the fix. * chore(lint): add anti-slop oxlint plugin (pinned, all rules off) (#20726) * chore(lint): add anti-slop oxlint plugin (all rules off) Vendors dmmulroy/anti-slop (MIT) plus no-call-only-assertions and no-pass-through-type-alias from maharshi365/deslop (MIT). Every rule starts "off"; each follow-up PR fixes one rule's violations and flips it to "error". * fix(lint): actually exclude the vendored plugin from the anti-slop audit oxlint does not honour ignorePatterns supplied via --config, so the config/oxlint-plugins/anti-slop/** entry never matched and the vendored rule source was being linted as first-party code (505 violations). Move the exclusion to the --ignore-pattern CLI flag in audit:anti-slop, which does work, and drop the entry that gave a false sense of coverage. Keeping vendored source unlinted matters because anti-slop is updated by three-way merge against the upstream snapshot; reformatting it locally would conflict on every update. * chore(lint): pin anti-slop instead of vendoring it; drop deslop Replaces the ~5k vendored lines with a git-pinned devDependency: oxlint-plugin-anti-slop: github:dmmulroy/anti-slop#c44ef22 anti-slop ships raw .ts with no build step, and Node refuses to type-strip anything under node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so oxlint cannot load it from there -- which is why upstream says to vendor it. A postinstall step copies the pinned package's source to .anti-slop-plugin/ (gitignored), which Node will type-strip because it sits outside node_modules. Upgrading is now a SHA bump rather than a re-vendor and three-way merge. Verified byte-identical rule output to the vendored copy across all 16 rules that fire. Drops maharshi365/deslop and its two rules (no-call-only-assertions, no-pass-through-type-alias). It is not on npm either, so it would need a second git pin and copy step, and it is a 5-star single-maintainer repo that is itself a re-namespaced copy of anti-slop. One upstream is enough. * ci(lint): run audit:anti-slop in PR CI config/scripts/pr-workflow-lint-parity.test.mjs requires every step in `pnpm lint` to have a matching step in .github/workflows/pr.yml; adding audit:anti-slop to lint without the workflow step failed that ratchet. Also makes audit:anti-slop sync the plugin itself before linting. The generated .anti-slop-plugin/ directory is gitignored and otherwise only created by postinstall, so a cached install that skips postinstall would leave oxlint unable to load the plugin. * chore(lint): enable anti-slop no-reduce-accumulator-copy and no-widen-then-assert (#20780) Both rules already report zero violations, so this only locks in the current state as a ratchet. No source changes. * fix(runtime): keep listed handles when graph sync learns a PTY incarnation (#20779) reconcilePtyIncarnationHandles compared a null retained incarnation against the learned one and staled the handle. Daemon-hosted PTYs are recorded from first output before the spawn commit reports an incarnation, so on Windows `orca terminal create` returned a handle that was stale by the next graph publish. Treat null-to-known as un-fenced like every other site; keep the known-to-different and preallocated-handle invalidations. * fix(orchestration): require registered structured worker pane key (#20664) * fix(lint): keep root postinstall as the sole Electron binary install owner (#20788) #20726 appended the anti-slop plugin sync to postinstall, which breaks the contract asserted by package-electron-runtime-contract.test.mjs and is failing on main. The sync is not needed there: audit:anti-slop already runs it before linting, so a cached install that skips postinstall still works. * Update README downloads badge * test(package): let the postinstall contract allow unrelated chained steps (#20787) * fix(pty): preserve child-process inspection uncertainty (#20756) * fix(pty): preserve unverifiable local child reads * fix(pty): make child-process inspection synchronous Separate foreground and child-process sampling. Sample child processes synchronously after confirming foreground availability, returning unverifiable verdicts when pty reads fail. Handle both transport loss and local read failures uniformly in the completion coordinator. * fix(pty): handle retired masters and pane instance swaps Detect when node-pty retires the master fd (fd == -1) and return unverifiable instead of misreading the spawn file as an idle shell. Guard inspectProcess against PTY replacement mid-read to avoid pairing old foreground with replacement's children. * fix test * fix tests * fix(lint): enable anti-slop/no-unknown-type-aliases (#20784) Flips anti-slop/no-unknown-type-aliases from "off" to "error" and fixes the 3 baseline violations. The rule rejects a named type alias whose resolved type is `unknown` (directly, through another alias, through parentheses, or as a member of a union). Such an alias is strictly worse than writing `unknown`: it reads like a real domain type at every use site while accepting anything, so the compiler stops helping and readers are actively misled. `unknown` is fine, but it must stay visible at the boundary that actually parses it. Violations fixed (3 at baseline, 5 source files touched): - src/main/runtime/workspace-session-failed-write-rollback.ts `type RollbackValue = unknown` -> a real recursive JSON-shaped union `RollbackSlot` (primitives | null | undefined | typeof MISSING | readonly RollbackSlot[] | RollbackRecord), with a named `type RollbackRecord = { readonly [key: string]: RollbackSlot }`. The record is a named alias rather than an inline index signature because inline violates typescript/consistent-indexed-object-style, `interface` violates consistent-type-definitions, and `Readonly<Record<..>>` trips TS2456 circular-reference. The named alias satisfies all three. - src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts `type DirectSshReconnectTimer = unknown` -> `ReturnType<typeof setTimeout>`, the handle that actually flows. `DirectSshReconnectTargetState.timer` is widened to `DirectSshReconnectTimer | null` to match the state machine, which initializes to null and resets to null in the scheduled callback. - src/renderer/src/hooks/direct-ssh-host-hydration.ts `type HostReadTimer = unknown` -> `ReturnType<typeof setTimeout>`. Fix pattern throughout: replace the alias with the type that already flows through the code, never with `any` and never with a relabelled `unknown`. Because the timer aliases are now honest, two pre-existing `as ReturnType<typeof setTimeout>` casts at the clearTimeout boundaries could be deleted, a net win under the repo's type-assertion policy. Suppressions added: none. No eslint-disable, oxlint-disable, `any`, or `as` cast was introduced anywhere in this change. The diff is type-annotation-only; no runtime statement changed. * refactor(lint): enable anti-slop/no-reflect-apply (#20782) `anti-slop/no-reflect-apply` rejects `Reflect.apply(fn, thisArg, argsArray)`. It defeats the call-signature checks TypeScript applies to an ordinary call: the args array is checked as an array, not positionally against the callee's parameters, so arity and type errors pass silently. Dynamic dispatch belongs behind a named interface, not behind a reflective call. Flipped the rule from "off" to "error" and cleared all 17 baseline violations across `src config tests mobile` (16 sites; one file had two). Fix pattern: `Reflect.apply(fn, recv, args)` becomes `fn.call(recv, ...args)`, or a direct method call when the implicit receiver is already the right object. The receiver is preserved at every site. Where the callee is a captured built-in whose overloads split on an argument's shape (`String.prototype.split`, `JSON.stringify`), a call-signature capture no longer compiles once the args are passed positionally. Those three sites capture the function through a method-shaped type (`{ split(separator: unknown, limit?: number): string[] }['split']`), which keeps the forwarding call checked rather than asserted. Behaviour notes: - `diff-section-layout.test.ts` drops a `limit === undefined ? [sep] : [sep, limit]` conditional. Equivalent: `String.prototype.split` maps an undefined limit to 2^32-1, and the `Symbol.split` path forwards undefined either way. - `workspace-space-compaction.test.ts` forwards `reduce`'s two arguments unchanged, so the `arguments.length >= 2` initial-value branch is unaffected. - `agent-session-history-byte-accounting.test.ts` is the one site where the receiver is not literally preserved (`JSON` -> undefined). `JSON.stringify` never reads `this` per spec, and restoring `.call(JSON, ...)` would reintroduce the overload failure under strictBindCallApply. No suppression comments added — the rule has zero `oxlint-disable` sites. `Reflect.apply` still appears at electron.vite.config.ts:159, inside a template literal of generated bootstrap source. That is string content, not lintable code. * fix(git): distinguish binary absence from missing cwd on spawn ENOENT (#20798) * fix(repos): preserve unknown Git availability * fix(git): distinguish binary absence from missing cwd on spawn ENOENT Node reports ENOENT for both a missing git binary and a missing working directory during spawn. The fix checks specifically for spawn syscall, then verifies the cwd exists to disambiguate. This prevents reporting "no Git" when the error is actually a missing working directory. Centralizes probe logic in a reusable function; other failures cause rejection so callers preserve the unknown status instead of collapsing to false. * fix(lint): enable anti-slop/no-module-mocking (#20783) The rule rejects `vi.mock` / `vi.doMock` / `vi.unstable_mockModule` and the `jest` equivalents, on the argument that a test which rewrites the module graph asserts against a stand-in the production code never sees. It is already off for `**/*.test.{ts,tsx}`, `**/*.spec.{ts,tsx}`, `tests/**` and `**/__mocks__/**` via the existing override in config/oxlint-anti-slop.json; that override is unchanged here. What the rule actually catches is module mocking that has drifted out of a spec and into a first-party `.ts` support module, where nothing marks it as test-only. 73 violations at baseline, all of them in test-support code. 9 were relocated back into spec files the override already exempts; the remaining 64 sit in 10 files that are test-only but do not match the override globs, and carry a file-level disable naming the rule and the reason. Relocated: - terminal-hydration-store-test-bootstrap.ts: the sonner / sync-runtime-graph / pty-transport `vi.mock` calls moved into the two specs that import it (terminals-hydration-canonical-rows, terminals-hydration-canonical-pty-overlap). Vitest hoists `vi.mock` inside a test file, so registration is strictly earlier than the previous module-eval-time call; the bootstrap keeps only the preload API proxy. Both importers were updated. - ipc-events-ssh-authority-test-fixtures.ts: the 6 direct-ssh `vi.doMock` calls moved into useIpcEvents-agent-status-ssh-authority.test.ts as a local `stubDirectSshModules()` helper, which also de-duplicates the three copies the spec already had inline. The fixture now returns the store state and coordinator doubles it builds, typed via the exported DirectSshReconnectCoordinatorDouble. Suppressed, with justification (each is `/* oxlint-disable anti-slop/no-module-mocking -- ... */`, rule named, no blanket disable): - config/scripts/headless-serve-shutdown-matrix.test.mjs (1) - a genuine Vitest spec that the override misses only because its globs say {ts,tsx}. The script under test is a top-level CLI module; the alternative is spawning real docker. - src/main/codex-accounts/runtime-home-service-test-harness.ts (1) - stubs one probe predicate in ../pty/shell-startup-env, imported directly by several main-process readers; 17 specs share it. - src/main/computer/desktop-script-provider-test-harness.ts (2) - stubs child_process/fs-promises for a provider that shells out; 8 specs share it. - src/main/github/work-item-search-test-harness.ts (4) - one consumer lives in tests/e2e, where the relative mock ids resolve differently, so moving the calls into the specs would silently stop mocking there. - src/renderer/src/components/automations/automations-page-test-harness.tsx (14) - the mount rig for 10 AutomationsPage specs. - src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts (1) - stubs refreshWebRuntimeSessionTabsSnapshot, imported directly by several renderer runtime modules; 18 specs share it. - src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts (7) - stubReactSyncEffect/stubAuxiliaryModules, shared by 11 specs. - src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts (11) - stubs and hook invocation are one unit; 4 specs share it. - src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts (13) - its only spec is at 799 of an 800 max-lines budget. - src/renderer/src/hooks/ipc-events-test-harness.ts (10) - shared by 8 specs. No violation was converted to real dependency injection, and no max-lines disable was added. Verified: the audit command exits 0 with no output (and reports errors on a planted probe, so the rule is live); node config/scripts/run-typecheck-projects-in-parallel.mjs exits 0; 354 spec files / 2506 tests covering every importer of every touched file pass. No mobile/ file was touched. The changed-code quality gate's root Oxlint scan runs without --config so it never loads the anti-slop JS plugin, which made all 10 of those file-level suppressions read as "Unused oxlint-disable directive". check-changed-code-quality.mjs now exempts directives naming an anti-slop rule from that unused-directive warning, the same carve-out isCastingDirectiveUnusedWarning already makes for the casting suppressions the casting config enforces. Such a directive can never suppress a root-config rule, so nothing the root scan would otherwise report is hidden; audit:anti-slop remains the scan that enforces the rule. * Improve microphone permission errors and drop failure reporting (#20801) * refactor(renderer): give the IPC error reader a clamped and an unclamped shape * fix(composer): name the attachments a drop could not add, in one toast * fix(composer, source-control): use one stable failure toast slot - Replace per-worktree toast IDs with single slot that replaces on each failure - Remove destructive retry actions; discard must confirm in dialog - Consolidate filesystem import types to shared location - Add compactIpcErrorMessage for string error handling * refactor: centralize filesystem import types and clarify failure naming Move import result types from main/ipc to shared layer so they're available across preload and renderer. Rename uniformFailure → commonFailure and skippedOrFailed → failureCount for clarity. Simplify preload/API type definitions by reusing shared types directly instead of duplicating inlined union shapes. * Reuse single toast slot for composer drop failures Multiple drop failures now replace the previous toast instead of stacking, preventing notification clutter. Uses a dedicated toast ID separate from Source Control's stage/discard notifications. * fix(settings): say when the microphone is blocked and where to grant it * Use generic stream for microphone permission requests - Request generic audio stream instead of saved device to handle stale device IDs (unplugged microphones). This ensures the initial permission grant succeeds even if the previously saved device is no longer available. - Refactor error handling to not require instanceof checks, supporting errors thrown as plain objects and improving robustness across browsers. - Simplify tests with proper typing and add coverage for stale device and permission error edge cases. * fix type check * minor type fix * Report clipboard and composer drop failures (#20795) * refactor(renderer): give the IPC error reader a clamped and an unclamped shape * fix(composer): name the attachments a drop could not add, in one toast * fix(composer, source-control): use one stable failure toast slot - Replace per-worktree toast IDs with single slot that replaces on each failure - Remove destructive retry actions; discard must confirm in dialog - Consolidate filesystem import types to shared location - Add compactIpcErrorMessage for string error handling * refactor: centralize filesystem import types and clarify failure naming Move import result types from main/ipc to shared layer so they're available across preload and renderer. Rename uniformFailure → commonFailure and skippedOrFailed → failureCount for clarity. Simplify preload/API type definitions by reusing shared types directly instead of duplicating inlined union shapes. * Reuse single toast slot for composer drop failures Multiple drop failures now replace the previous toast instead of stacking, preventing notification clutter. Uses a dedicated toast ID separate from Source Control's stage/discard notifications. * fix(source-control): surface a failed notes copy instead of swallowing it * Simplify diff comment notes copy error message Replace parameterized translation template with a direct string. Add explicit type annotations in tests to improve type safety. * Sanitize clipboard write error messages for user display - Only user-friendly messages for recognized errors - Native failures logged but not exposed to UI - Prevents information disclosure (CWE-209) * test(package): reject an Electron install takeover by exact command (#20799) * test(package): reject an Electron install takeover by exact command CodeRabbit was right about #20787. Replacing the pinned postinstall string with a /electron/i keyword check was wrong in both directions, verified: rebuild-native-deps.mjs && rebuild-native-deps.mjs PASSED (should fail) rebuild-native-deps.mjs && check-electron-version FAILED (should pass) The owner's own path contains no "electron", so duplicating it slipped through -- the one case the contract is named for. And a substring match rejects any later step that merely mentions Electron, which is the same over-tightness that broke every open PR in the first place, relocated. Later steps are now checked against the exact owned command plus the known Electron install commands. A second case pins the rejections themselves, because reading the real postinstall cannot show a bad chain would be caught -- that is how #20787 shipped with a guard that did not guard. Split into its own file rather than adding a max-lines disable (AGENTS.md). * test(package): match install commands as tokens and cover the rebuild:electron alias Both review comments were right, verified by running them: && check-install-app-deps-version.mjs rejected by substring match (should pass) && pnpm run rebuild:electron slipped through (should fail) package.json:101 aliases rebuild:electron to the owned script, so invoking it is the same takeover. Matching is now token-based with the owned command still checked as a phrase, and both cases are pinned. * fix(worktree): block removal when the archive hook fails (#20153) * fix(worktree): block removal when the archive hook fails A repo's orca.yaml archive hook is the user's last chance to save work off a checkout Orca is about to delete. A failed hook was logged as advisory and stepped over, so the removal went ahead with nothing archived — and the caller could still be told it succeeded. The hook is now a blocking precondition, evaluated while the checkout, its Git registration, its agents and Orca's ownership evidence are all still intact: it sits ahead of the registration re-read, the lock/dirty preflights, stopPtys() and removeWorktree in every orchestrator that runs it. Failure is typed (worktree_archive_hook_failed) and carries the worktree path, outcome, exit code where one was observed, and the hook's output. unverifiable stays distinct from exited, so loss of contact is never read as a pass. The waiver rides its own field at every layer and is never implied by --force, which already carries the PTY-stop waiver; when used, the waived failure comes back on result.archiveHookOverride rather than being swallowed. worktree.archive-failure-blocking.v1 is advertised so an integration can tell "accepts --run-hooks" from "safely propagates a failing hook" without risking the data loss to find out. The runtime's SSH path cannot run a hook at all, so rather than delete with the archive step silently skipped it refuses — waivable like every other refusal here. #18563 retires that gate by making the path run the hook for real. Stacked on #20559, which makes a timed-out hook report honestly; without it a hook that traps SIGTERM and exits 0 would defeat this gate. Fixes #19334 * fix(worktree): close the skip-confirm dead end and the client/hook timeout gap Four review findings on the gate. A retry from the failure toast could fail for a DIFFERENT reason than the one the user had just answered, and that second failure got a bare toast with no buttons. With skipDeleteWorktreeConfirm set, the delete helpers pass no force, so waiving a failed archive hook on a dirty checkout landed on the dirty preflight and stopped there. Retry failures now re-enter the same failure toast, so every retry stays as actionable as the first attempt. Third instance of this class. The renderer gave worktree.rm a 60s budget while an archive hook may run for 120s. A hook that took 90s and succeeded timed the client out and reported failure while the host went on to delete — telling the user their delete failed and their checkout was gone. The budget is now derived from the hook's, and only when a hook can run. The SSH fail-open is logged rather than silent, and the capability's doc comment scopes what it claims: a hook that RUNS and fails cannot delete the checkout; it is not a promise the hook was found. The SSH owner-resolution test now reads a real remote orca.yaml through a stubbed provider and asserts the returned script is the remote one. It previously stopped at the lookup key, which is the coverage that let this path break twice. It fails against the row-only resolution. * fix(worktree): name a signalled hook exit, and state why prunable cleanup skips the gate Two things the rebase onto #20617 and #20576 surfaced, both found by rerunning the real-repo harness rather than by reading the diff. - #20617 added a registration-cleanup branch that returns before the archive gate. That ordering is correct — both of its arms describe a row with no checkout behind it, so there is nothing to archive and running the hook would fail on the missing cwd — but the gate's ordering invariant is documented, so the exception should be too. - A signalled hook reported `Command failed with exit code null.`, which reads as a reporting glitch rather than the `unverifiable` verdict it is about to produce. It now says the command was terminated without reporting an exit code. Introduced by #20576; the withheld `exitCode` itself was always right. Fixes #19334 * fix(lint): enable anti-slop/no-reflect-get (#20786) `anti-slop/no-reflect-get` rejects every call to `Reflect.get`. The reflective read bypasses ordinary property access and throws away the type evidence the compiler would otherwise give you: the result is `any`/`unknown` with no narrowing, so a typo in the key or a shape drift in the source object is invisible until runtime. The rule's remedy is to parse dynamic input into a named domain type (or narrow it with `in`) and then read the field normally. Baseline: 86 violations across 67 files. Now zero unsuppressed violations under `npx oxlint --config config/oxlint-anti-slop.json --ignore-pattern 'config/oxlint-plugins/anti-slop/**' src config tests mobile`. Fix pattern ----------- 44 of the 86 were rewritten. The dominant shape was an `unknown` value read through `Reflect.get` right after a `typeof === 'object'` guard; those became `in`-narrowed property access, which TypeScript checks: - Reflect.get(value, 'agents') + 'agents' in value ? value.agents : null Two further shapes: - `Reflect.get(Object(x), 'k')` on a possibly-primitive envelope became a small named reader that boxes once and indexes a `Record<string, unknown>` (`settingsField` in mobile/src/transport/settings-read-operations.ts). - Tests reaching into private state moved to TypeScript's checked bracket-index escape hatch (`runtime['layoutQueues']`), or to a documented read-only accessor on the owning class (`SearchSubprocessLineAccumulator.retainedCapacityBytes()`, `CodexSubagentExecutions.retentionSizes()`). No type assertion was added anywhere: the diff contains zero net-new `as` casts, `as any`, `as unknown as`, `@ts-ignore`, or `@ts-expect-error`, so nothing was laundered into the sibling assertion rules. Suppressions ------------ 42x `// oxlint-disable-next-line anti-slop/no-reflect-get` across 38 files. Every one is the default-forward branch of a `Proxy` `get` trap: get(target, property, receiver) { ... return Reflect.get(target, property, receiver) } `Reflect.get(target, property, receiver)` is the only construct that forwards with correct `receiver` semantics; `target[property]` invokes an accessor with the wrong `this` and silently breaks getters that read sibling state. There is no typed alternative, so these are suppressed rather than rewritten. 3x `// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface` in tests/e2e/github-url-smart-input-transition.spec.ts, tests/e2e/linear-url-workspace-entry.spec.ts, and tests/e2e/worktree-active-delete-scroll-position.spec.ts. Replacing `Reflect.get(window, 'x')` with typed `window.x` requires a `declare global { interface Window }` block, and `interface` is mandatory for declaration merging. Matches the existing convention at tests/e2e/helpers/runtime-types.ts:63. 1x `// eslint-disable-next-line no-var -- main-process gate handle for this spec` in tests/e2e/project-group-creation-visibility.spec.ts, for the same reason a `var` global is needed to type the handle. Matches tests/e2e/agent-session-log-tail-stability.spec.ts:24. Also updates two source-text anchors in mobile's rpc-recording mutation harness (mobile/src/test-support/rpc-recording/operation-mutations.ts and recording-runner.test.ts), which pin the exact text of the rewritten line in settings-read-operations.ts and would otherwise fail with "Mutant anchor matched 0 sites, expected 1". * fix(relay): open the real null device when detaching Windows stdio (#20808) * fix(relay): open the real null device when detaching Windows stdio `openSync('NUL')` does not reach the null device on Windows. node's fs runs the path through `toNamespacedPath`, which resolves it against cwd and prefixes `\\?\` — and that prefix turns off DOS device-name mapping, so CreateFileW creates a regular file named `NUL` in the relay's install dir and pins fds 0/1 to it instead of to a discard sink. Verified on a Windows 11 host: `fs.openSync('NUL', 'w')` + a 5-byte write produced a 5-byte file named `NUL` in cwd. `\\.\NUL` is passed through `toNamespacedPath` verbatim; the same write discards and a read answers EOF, with no file created. It also escaped into shipped artifacts. release-cut.yml runs the relay watcher fault harness with cwd = out/relay/win32-x64, so every Windows installer since v1.4.169 carries `resources/relay/win32-x64/NUL`, which NSIS extracts as `_NUL`. * test(relay): prove the `\\?\` rewrite on a drive-letter path `toNamespacedPath('NUL')` off Windows only resolves against a POSIX cwd and stops; with no drive letter it never reaches the branch that adds `\\?\`. So the assertion held for the wrong reason and did not demonstrate the rewrite the comment describes. Assert it on an absolute drive path, which takes the same branch on every host. * build(release): compile the Windows relay process-table addon (#20809) * build(release): compile the Windows relay process-table addon #16598 added build-windows-process-tree-relay-addon.mjs and the ORCA_REQUIRE_RELAY_NATIVE_ADDONS gate, but wired both into dev-channel-win-build.yml only. release-cut.yml was never touched, and stageWindowsProcessTreeAddon merely logs when the addon is absent, so every stable release has shipped Windows relays without windows-process-tree.node. Confirmed by extracting the installers: v1.4.191 (the first stable carrying the feature), v1.4.198 and v1.4.203 all have no windows-process-tree.node in relay/win32-x64 or relay/win32-arm64. Those hosts have been taking the CIM fallback the whole time — 1247ms and a powershell.exe per scan against 57ms native, on #16598's own ~1490-process measurement host. Mirror the dev-channel steps. Same windows-2022 image, so the MSVC ARM64 cross toolset the arm64 leg needs is already proven there, and the addon build runs before the long packaging step so a missing component fails in seconds with MSB8020 naming it. * build(release): keep the Build app env rationale attached to its step The new addon step landed between the ORCA_POSTHOG_WRITE_KEY / BUILD_IDENTITY / DIAGNOSTICS_TOKEN_URL comment block and the Build app step it documents, orphaning it. Move the step above the block and record why it carries no run_attempt guard: Build app is ungated, so a guarded addon step would let a rerun reach the required-addon check with nothing staged. * fix(automations): repair cron step expansion and day restriction (#20202) The semantic half of the cron repair. Both defects change what an already-saved schedule does, so they ship together and behind a decision. #15723: parseCronField set end = start for a bare numeric field even with a slash step, so 5/15 expanded to [5] and fired hourly instead of every fifteen minutes. N/step is the open-ended N-max/step sequence now. #15896: day restriction came from expanded set cardinality, so 1-31 read as unrestricted and */2 as restricted. Restriction is lexical now: a day field restricts iff no term of it ranges over a star, matching vixie cron and robfig/cron rather than crontab(5)'s prose. Verified differentially against robfig/cron v1.2.0 across 22 expressions, 424 days, zero divergences. The two cannot ship apart: 0 9 1/1 * 1 matches 124 days under the old parser, 104 under #15723 alone, and 730 under both, because the old cardinality flags react to the corrected expansion. describeAutomationScheduleDrift reads a saved expression under both semantics and reports the ones that moved, so neither direction is silent; the service names them once at startup. No expression Orca's own presets generate drifts. Fixes #15723 Fixes #15896 * fix(lint): enable anti-slop/no-object-parameters (#20781) The rule rejects the broad `object` type on any function input (declarations, expressions, arrows, methods, call/construct signatures, function types), plus local aliases and unions that resolve to `object`. `object` accepts every non-primitive while exposing no properties, so it documents nothing and pushes callers into assertions at the boundary. Fixes all 185 violations across src, config, tests and mobile, and flips the rule from "off" to "error" in config/oxlint-anti-slop.json. Approach: replace each `object` input with the type its owner already has. Most sites took an existing domain type or a type-only import (36 added); 40 new aliases name shapes that had none. Where a value is genuinely only compared by reference, it gets a named identity token instead of a shape -- `Record<string, never>`, the built-in `WeakKey`, or a `unique symbol` brand, matching the branding already used in src/shared. Same treatment for WeakMap and Map key parameters. Two `as unknown as` casts became unnecessary once the parameter carried a real type and were removed; no new casts were added. Suppressions added: none. No `oxlint-disable` for this rule anywhere, and no max-lines disable or per-file bump. Three files sat exactly at their max-lines cap, so the added type imports were made line-neutral rather than suppressed: - src/main/ipc/browser.ts exports the existing guest-registration args type (renamed BrowserGuestArgs) so browser.test.ts reuses it on one line. - pane-scroll.ts takes TerminalScrollIntentTarget through the existing pane-manager-types import via a type-only re-export. - direct-rpc-client.ts drops the identity parameter entirely: the session check moved into the sendProbe callback that owns the token. Verified: anti-slop config reports zero violations over src config tests mobile; run-typecheck-projects-in-parallel exits 0; 144 affected test files pass (1749 tests); oxlint and oxfmt clean on all changed files. Mobile has no runnable test/typecheck target in this worktree (expo is not installed), so its 6 files were typechecked against a standalone config and diffed against the base branch -- error sets are byte-identical, including test files. * fix(lint): enable anti-slop/no-shape-in-symbol-names (#20785) Flip `anti-slop/no-shape-in-symbol-names` from "off" to "error" and clear every violation under src, config, tests and mobile. What the rule bans ------------------ The case-insensitive substring "shape" in any JS/TS identifier: variables, functions, parameters, types, type parameters, class members, private names, object-literal keys and JSX identifiers. The one exemption is a statically accessed member read owned by another value (`zodObject.shape` is fine), so third-party APIs stay readable without a suppression. "Shape" names a value's structure rather than its domain role. `UserShape`, `validateArgShape` and `errorShape` all tell you the symbol is "an object with some fields" -- which is already what a type says -- while saying nothing about what the value is for or who owns it. The rule forces the name to carry the domain instead. Violations fixed ---------------- 689 violations across 109 files at baseline (verified by re-running the audit against the pre-change tree with the rule set to "error"). Fix pattern ----------- Rename for the domain role, not the structure: -type FieldShape = 'list' | 'map' | 'whole' -const FIELD_SHAPES = { ... } satisfies Record<keyof Observation, FieldShape> +type FieldEncoding = 'list' | 'map' | 'whole' +const FIELD_ENCODINGS = { ... } satisfies Record<keyof Observation, FieldEncoding> -function assertGitPushTargetShape(target: unknown): void +function assertValidGitPushTarget(target: unknown): void -function describeReadDirPathShape(p: string): ReadDirPathKind +function classifyReadDirPath(p: string): ReadDirPathKind Predicates became statements about the value (`isDeltaShapedProviderFrameKind` -> `isDeltaProviderFrameKind`, `isDeleteShapedDiscardEntry` -> `discardDeletesEntryFile`, `isSkillsCliAgentKeyShaped` -> `isUsableSkillsCliAgentKey`). Type aliases dropped the suffix where the remaining name was already unambiguous (`GhGraphqlErrorShape` -> `GhGraphqlError`). No wire-visible name was renamed: no IPC or RPC channel, stream opcode, request/response param, persisted field, or i18n key. The `--shape=symlink|copy` CLI flag read by .github/workflows/skill-update-roundtrip.yml is unchanged -- only the local variable holding it was renamed. Exemptions ---------- They are file-scoped entries in config/oxlint-anti-slop.json, not inline `oxlint-disable` comments. An inline directive naming an anti-slop rule reads back as an UNUSED directive under the root lint scan, which does not load this plugin -- the changed-code quality gate counts that warning, so the comment form cannot be used for a rule that lives only in this config. * src/renderer/src/components/browser-pane/annotate/**: in the screenshot annotator a "shape" is the drawn geometry -- pen, arrow, rect, ellipse, highlight. That is a genuine domain noun, and it pervades every symbol in the module. * repo-icon.tsx, repo-header-project-actions.tsx, mobile MobileRepoIcon.tsx: lucide exports the icon component as `Shapes`. The name is theirs, and the matching REPO_LUCIDE_ICONS key is the persisted icon name shared with the desktop picker -- renaming it would orphan saved repo icons. * src/shared/onboarding-state-types.ts, src/shared/constants.ts: `shapedSidebar` is a persisted onboarding-checklist field and a telemetry enum member; renaming it would orphan saved state. * src/shared/rpc-contract/rpc-send-params.ts: matching zod's own literal `shape` property is what selects the ZodObject branch of the conditional type. No exemption was added merely to avoid a rename. Eight symbols initially suppressed as "a cross-module refactor outside this change" were proven to have zero non-TypeScript references repo-wide and renamed instead. Zod's `ZodRawShape` needed no exemption at all: `Readonly<Record<string, z.ZodType>>` is its definition, so repo-update-params.ts and ui-update-value-tolerance-params.ts spell it out instead. Likewise telemetry-event-classification.ts now reads `.shape` through an `in` narrowing, which also retires two pre-existing type assertions; three more assertions the rename had dragged onto changed lines (two `JSON.parse` sites, one node:sqlite row read) became annotations and an explicit row mapping. Verified -------- * Audit reports zero violations; confirmed the rule genuinely fires by planting a probe violation. * node config/scripts/run-typecheck-projects-in-parallel.mjs exits 0. * Vitest over src/shared, src/main/github/project-view, the annotate module, the repo-icon components and the Chromium SameSite electron spec: all green. * All 66 removed "shape" identifiers grepped repo-wide across every file type; none survive. * node config/scripts/generate-rpc-params-catalog.mjs --check exits 0. * node --check on every changed .mjs; oxfmt clean on all changed files. * `pnpm run check:code-quality:changed` reports 0 findings. Not machine-verified: the 3 mobile/ files (its Vitest run cannot resolve `expo/tsconfig.base.json` in this worktree), and the WSL- and Playwright-gated specs. All are rename- or comment-only hunks, read in full. * fix(automations): stop tick latency counting against the missed-run grace (#20819) * fix(automations): stop tick latency counting against the missed-run grace The scheduler compared wall-clock lateness straight against the grace budget, but evaluation runs on a fixed 60s interval that is never aligned to an occurrence. With grace 0, any tick arriving after the scheduled instant -- in practice every tick -- recorded skipped_missed and told the user "Orca was unavailable during the missed-run grace window" while Orca had been up the whole time. A zero-grace automation effectively never ran. Grace is a downtime catch-up budget. An occurrence that came due while the scheduler was running was never missed; it is waiting for the next tick. The service now tracks continuous availability and only charges lateness to grace for occurrences that came due while it was stopped. Downtime behaviour is unchanged, and the new test asserts that half too. The missed-run branch moved to dispatch-refusal.ts, which already owns non-dispatch outcomes, keeping service.ts under max-lines without a disable. Fixes #11299 * fix(automations): use a tick-latency tolerance instead of process liveness Review caught two real defects in the first cut: - availableSince is process liveness, not continuous execution. A suspended process (system sleep) keeps its start time, so an occurrence that came due during a multi-hour sleep skipped the grace check entirely and replayed on wake -- exactly the downtime case grace exists for. - The restart edge: an occurrence due after the last tick but before stop() was reclassified as downtime and skipped with zero grace. Elapsed lateness cannot be faked by suspension and needs no restart bookkeeping, so the budget is now grace + two tick intervals. Both edges disappear rather than being special-cased. Also fixes a hollow test: workspaceId 'wt1' has no worktree separator, so the target refused and the run recorded skipped_unavailable -- a 'not skipped_missed' assertion passed without ever dispatching. Tests now use a valid id and assert 'dispatching' directly, and cover the sleep, tolerance boundary and restart cases. * fix(automations): scope to the verified tolerance and document the stall gap Review found three defects, all real: - The 'as never' cast failed the changed-code casting gate. AutomationRendererChannel is a Pick<> precisely so a test can pass the real shape; cast removed. - The restart test never restarted: evaluateAt advanced 60s internally, so the first pass already dispatched and the second was a no-op. It now evaluates exactly once and asserts no run exists before the second pass. - tickMs * 2 does not bound a pass that holds the re-entrancy guard across a slow serve-mode dispatch. I tried a busy-window fix for the third and could not test it honestly -- the case needs a genuinely slow in-pass dispatch, and both attempts passed with the fix disabled. Rather than ship logic I cannot prove, the tolerance stays at the verified shape and the gap is documented where the next reader will find it, with the reason 'time since last pass' is the wrong bound (a suspended process runs no passes either). Not a regression: on main that automation never ran at all. * fix(automations): name the check for what it does and correct its message Two review points, both fair: - missedDuringDowntime consulted nothing about availability once the liveness flag was removed; it is elapsed lateness against grace plus tolerance. Renamed missedBeyondGrace so callers read the real contract. - The run error still claimed 'Orca was unavailable' -- the same false statement #11299 was filed about, now reachable for a genuinely late run rather than a merely tick-delayed one. It states what was actually observed instead. Also documented the deliberate trade CodeRabbit raised: elapsed lateness cannot tell a short outage from a late tick, so a zero-grace run due during an outage shorter than the tolerance dispatches instead of skipping. The alternative got the far worse case wrong -- a multi-hour sleep replayed on wake. * refactor(mobile): migrate the small domains onto RpcOperation (step 4) (#20705) * refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's `recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its merge with main conflicted on that one line in 153 files; every future domain PR would collide with every other in flight the same way. Split the directory at a real seam instead of a filename convention: `adapters/` holds one module per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers the engine only. A new `adapterSha256` covers the source of the module that mounts each operation a golden's scenarios drive, read off the same `mounts` calls that build the table the recording runs against, so the pin cannot name a file the runner did not use. Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file, and an adapter importing a sibling each fail. The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new header field; the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the split recorder/adapter digest Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers `adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and recording ran against the same pinned product tree. git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l 0 The seven `adapterSha256` values partition the 153 goldens by the module each was recorded through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory, 9 tasks, 8 workspace settings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop pinning goldens to recorder inputs no recording can read The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the per-family mutant registry beside it, and the probe-hole witness. None can change a recording -- the loader consults a mutant only when a mutant test asks for one, and no suite but the two recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have and charged every domain a full re-record for it. `mutants/` now holds the table, the registry, the reference states, the mutant suites and the probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts` checks exactly that, and fails if an engine file names the directory or anything outside imports from it. `recorderSha256` also pins only the suites in `recording-drivers.ts`, which `scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or writes one to a scratch directory, is no longer provenance for a recorded file. `OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden. Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to `root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed anything. Each call now spells the root differently. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the mutant and driver exclusions Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153 because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that module now carries its own exposure declaration. git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256)":' | wc -l 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the preferences actions the merge resolution dropped #20568 added `resume` and `trust` actions to the `settings.task-preferences` adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the `pilot-mount-adapters.ts` conflict in favour of the registry merge silently discarded them and `tw-task-preferences-resume-write` failed to record at all ("Missing or completed request: ui.set#1"). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens at main's tip after the merge All 208 goldens, header-only. `baseline` moves from50e752fc66to main's tipc6a7216984, `goldenFormatVersion` from 4 to 5, `recorderSha256` to the value of the engine with `adapters/` and `mutants/` carved out, and `adapterSha256` is new on every file. Nine distinct adapter digests over 208 goldens: each golden now pins only the module that mounts it. No observation moved. The whole-diff census against origin/main reports exactly four changed keys and nothing else: 208 "adapterSha256": 416 "baseline": 416 "goldenFormatVersion": 416 "recorderSha256": Recorded in place rather than through the README's detached-baseline dance: this branch changes no product file, so its tree at the merge is byte-identical toc6a7216984under mobile/src, src/shared and the lockfile, and the parity claim stays non-circular. README says so now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the recording drivers to the engine's mutant-seam rule The name scan exempted every `.test.ts` on the ground that a test cannot change a recording. Two of them can: the recording drivers are the recording path. A driver that read the mutant table by path rather than importing it passed both seam checks — the import scan sees no import, and the name scan waved it through as a test: const table = resolve(import.meta.dirname, 'mutants/operation-mutations.ts') console.log(readFileSync(table, 'utf8').length) at the top of `pilot-recordings.test.ts` gave 2 passed before, and after this change fails with ["pilot-recordings.test.ts"]. Only non-driver tests are exempt now. This file lives in `mutants/`, which `recorderSha256` skips, so no golden moves: the recorder suite is green on the existing 208 with zero dirty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the registry parameter no caller varies `pilotMountAdapters` took `registered` so a caller could mount a different module set; all six callers take the default. The header-digest tests vary the registry through `goldenRecording`, which keeps its own parameter and is where the stub roots need it. Engine source, so `recorderSha256` moves and the goldens follow in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens after the registry parameter came out All 208, `recorderSha256` only. The re-record against the previous commit moves 416 lines, every one of them that field: 416 "recorderSha256": Against origin/main the picture is unchanged from the merge: 208 goldens, 0 added or deleted, 0 non-header lines, and exactly four keys differing — 208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256" Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): wrap the recording README at the width the rest of it uses Seven lines this branch added ran past 100 columns, worst 124. No wording changed. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the worktree overlay, not the archive that cannot work `git archive` was offered alongside a detached checkout as a way to lay this branch's recorder over the pinned baseline. It cannot work: the fence in scripts/rpc-recording.mts runs `git diff --quiet <baseline>` and an untracked-file check, both of which need a real `.git`. In an archive tree git exits non-zero for lack of a repository and the script reports "Product sources or lockfile differ from the pinned main baseline", which reads as a product mismatch that is not there. The transport agent lost time to exactly that. Names `git worktree add --detach` only, and says what the misleading failure looks like if someone tries an archive anyway. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close two ways an adapter module escapes its own digest Two holes, one class: the seam was checked by how an import was spelled and by what the register's values evaluated to, never by where they resolve or where they were written. Inward imports: the scan dropped every specifier starting with `..`, so `'../adapters/settings-mount-adapters'` climbed out of the directory and back into it unseen. A reviewer had `new-tab-agent-mount-adapters.ts` project a value read from the settings module, edited that module, and watched the mounted state change while the new-tab adapter digest held. Specifiers now resolve against the directory and anything landing back inside it fails: ["new-tab-agent-mount-adapters.ts imports ../adapters/settings-mount-adapters"] The register: `adapters/mounted-operation-modules.ts` is pinned by nothing — `recorderSha256` skips the directory and `adapterSha256` reads each entry's `source`. An `exposes` written inline there drives the mounted product module with no digest covering it. The same reviewer replaced the new-tab entry's `exposes` with a literal overriding `loadMobileNewTabAgentOptions`; twelve fence tests passed. Both `mounts` and `exposes` must now be identifiers the register imports from that entry's own module: ["new-tab-agent-mount-adapters.ts writes exposes inline instead of importing it"] Checked on the register's syntax, not its values, because an inline literal and an imported binding are indistinguishable once evaluated. Pinning the register in the engine digest would also close it, and is the wrong trade: every domain adding a register line would re-digest all 208 goldens, which is the conflict this PR exists to remove. Keeping the register an index costs nothing and keeps a domain's line local. Both fixes live in a `.test.ts` outside the drivers, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the mutant seam from the drivers out, not by spelling The seam rested on a grep for the literal `mutants`, which the exported `MUTANT_DIRECTORY` spells without containing. A reviewer had `pilot-mount-adapters.ts` read the mutant table through that constant and both checks passed. The README's claim — that nothing on the recording path names the directory — was false as written. Three changes, in order of strength: Reachability is now proved forward. The suite walks the static import graph from the two recording drivers and fails if any module under `mutants/` is in it. That answers the real question, what a golden's bytes can depend on, instead of the old inward scan's question, who mentions this directory. Non-emptiness is asserted on both sides so a graph that resolved nothing cannot pass by reaching nothing. The name scan covers both spellings, for paths a module can be read by rather than imported. The reviewer's probe now fails as ["pilot-mount-adapters.ts"]. `MUTANT_DIRECTORY` is no longer exported. Its two consumers were both tests of the digest, and they now spell the path instead, which is strictly better for them: a test that imports the constant follows a rename silently, while one that spells it fails on a rename — and that specific directory name is the whole soundness argument. This edits `recorder-digest.ts`, so the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens after MUTANT_DIRECTORY stopped being exported All 208, `recorderSha256` only. Against the previous commit the diff is 416 lines and every one of them is that field: 416 "recorderSha256": Against origin/main, unchanged: 208 goldens, 0 added or deleted, 0 non-header lines, four keys differing — 208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256" Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the mutant seam's actual argument, and its edge The README claimed nothing on the recording path names `mutants/`. That was the old inward scan's claim and a reviewer falsified it with the exported constant. It now describes what the check does: a forward walk of the import graph from the two recording drivers, plus a name scan in both spellings for read-by-path, plus the constant no longer being exported. It also names the case neither closes — a path assembled from fragments at runtime. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the engine/adapter seam in both directions The inward scan only held adapters to the seam. An engine file importing an adapter executes code its own digest skips and that every golden recorded through another domain leaves out of `adapterSha256`, so the register is now the only crossing allowed from the engine side. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name what the driver walk missed instead of counting it Seeding `seen` with the drivers made the driver-presence check true by construction, and the size bound compared a graph inflated by `typeof import` product modules against a recorder-sized number. Both go; the walk now reports the recording files it failed to reach, which is empty today and names an orphan engine file the moment one appears. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): reflow four paragraphs left ragged by the rewrap Orphan fragments only, no wording change: the golden-schema field list, the mutant-evidence paragraph, the probe-witness sentence and the re-anchor note. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the small-domain call sites before migrating them Thirteen new families cover the files, new-workspace, host-screen, home and worktree call sites step 4 migrates next: ownership capture, the preview loader and its terminal-artifact grant refresh, the artifact save, the tab doc's three shapes, the drawer's execution target and setup hook, the Codex reset-credit probe, the host view settings, the Home stats card and the three workspace catalog reads. Recorded against main's product code, so these are the parity baseline the refactor must not move. Four new adapter modules under adapters/ and no engine edit, so recorderSha256 is unmoved and every existing golden is byte-identical: 40 files added, none changed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the small-domain reads through RpcOperation Thirty-five of the domain's fifty-five raw-port references now go through a declared operation: the files domain's preview, artifact and tab-doc reads and its ownership capture, the New Workspace drawer, the host screen's metadata and view-settings mirror, the Home stats card, and all three workspace catalog reads. No behaviour change, and the oracle says so: zero goldens move. Acceptance is preserved call site by call site, including two that look like defects and stay that way — a refused worktree.listRetiredNames still settles as an empty registry rather than holding the previous names, and a refused ui.get on a null result still throws into the host screen's own catch. Where two call sites disagreed about one method, both policies are named: files.read and files.readPreview throw for a session file tab and skip for the preview screen, repo.hooks throws for task create and skips for the drawer, and status.get now carries a fourth family for the Codex capability probe's object-or-null rule. The drawer's SSH connect, SSH state and agent detection reuse the workspace-create operations the tasks migration already declared rather than restating them. Two things outside the call sites. requestSingleFlight now shares the params optionality rule that request already had, so an all-optional schema such as preflight.check can omit its params on both helpers instead of only one; that is type-level and puts nothing new on the wire. And the retired-names fixture resolved a reply with no `ok`, a shape no host sends, which read as a refusal once the acceptance policy routed on it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive the image arm of the preview loader The mutation census found two migrated operations that no recording reached: repointing files.readPreview or files.readTerminalArtifactPreview to a wrong method, and swapping either one's acceptance policy, changed nothing any golden observed. Both preview-load scenarios read a text path, so the loader's image branch was migrated with no wire behind it. Two scenarios now read an image path through each arm, and the adapter takes the path from the scenario instead of a constant. All four mutations die on the new goldens. They are recorded from the pinned baseline with this branch's recorder laid over it, so they are main's behaviour and not the migration's: the candidate run against the refactored tree compares clean. The adapter edit re-digests the nineteen goldens mounted through it. The diff is one adapterSha256 line each and no observation moves, which is what pinning the adapter per golden rather than per suite is for. Two casts also take the SAFETY form the house style asks for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the casts the changed-code gate flags Seven type assertions the gate counted as new, all removed rather than silenced where the type system could already answer. `normalizeMobileFilePreviewResponse` narrows on `ok` instead of asserting each arm; the snapshot adapter narrows on the fetch result's own discriminant; and the drawer's two probe reads go through one overloaded member read that keeps their optional-chaining behaviour. The remaining three keep a cast and now carry the rationale on the asserting line. No behaviour change. The two adapter edits re-digest the sixteen goldens mounted through them, one adapterSha256 line each with no observation moved, recorded from the pinned baseline the same way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the import the narrowing left behind RpcSuccess is no longer named once the response reads through its own discriminant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the host screen's worktree mutations Review of #20705 showed the `use-host-worktree-actions.ts` holdout reason was wrong: its only native call is the pinned-id write, and that sits behind `if (hostId)`, so mounting with no hostId never reaches it. Two scenarios in one new family, recorded from the pinned baseline with the call site still on the raw port. The first drives all three sends so the reply matrix covers each method; the second refuses `worktree.rm` to pin the row restore. The adapter is a new module, so no existing golden's `adapterSha256` moves and none of the 250 goldens already here is rewritten. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the host list's worktree mutations through RpcOperation Pin, remove and activate move onto operations in host-screen-operations.ts. All three skip on refusal, which is the policy each site already applied by hand: the pin and activate writes swallow everything in a `.catch`, and the remove restores the row on a refused reply. `worktree.set` therefore carries a second policy next to source-control's `worktree.set-review-link`, which throws; both are named. Zero goldens move. The inventory loses use-host-worktree-actions.ts and states the real reason the drawer's repo list stays: it renders the last-visited-repo hook, whose default import of async-storage the recorder's proxy refuses at module load, before the hostId guard the reviewer expected to save it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the worktree-action fixture row and follow the activation scan The fixture row I recorded from had four fields, which `tsc` rejects as a `Worktree`. Filling it out moves the five goldens of this branch's own new family and nothing else; the recorded sends are unchanged, only the projected row is. `mobile-worktree-activation-source.test.ts` scanned the hook for the literal `sendRequest('worktree.activate'`, which the previous commit replaced. It now asserts the operation call and its two flags in the hook, plus the method in host-screen-operations.ts, so the pair still pins the same wire. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): take the five deletions round-1 review asked for - `fileOwnershipRuntimeStatusRead` was `taskRuntimeStatusRead` field for field. It is now a re-export of it. The goldens are keyed on the scenario family, not the operation name, so `matrix-files.mutation-ownership-status.get-1.json` survives unchanged. - `readProbeMember`'s two overloads asserted shapes nothing checked. Gone; the nested read goes through the same single-signature function. - `normalizeMobileFilePreviewResponse` had no product caller. Deleted with its re-export; its twelve assertions now drive the accepted and refused arms directly. - The three inline copies of the accepted-result union are gone. They name each operation's own `interpret` return instead of importing `RpcAcceptedResult`: importing the contract would pull all three call sites into the cast fence, where their existing SAFETY assertions fail it. - `codex-reset-credit-capability-operation.ts` is now `-operations.ts`. No adapter names it, so no golden re-digests. Zero goldens move. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the skip verdict its own transport module The three settle helpers typed their interpret parameter as `ReturnType<typeof <op>.interpret>`, naming one operation while being called with others whose verdicts happen to be structurally identical. Narrowing a named reader would have silently retyped unrelated helpers. `RpcAcceptedResult` moves to `rpc-accepted-result.ts`, outside the cast fence's three region seeds, so a consumer can name the verdict without becoming an operation implementation. `rpc-operation-contract.ts` re-exports it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop three sender aliases nothing imports MobileHostScreenRpcSender, MobileNewWorkspaceRpcSender and MobileWorktreeCatalogRpcSender each appeared only in the file that declared them. A named type with no consumer is a cost, not a boundary. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why a holdout is a holdout in the port inventory A site can be pointed at an operation without being mountable, so "cannot be migrated" was the wrong claim. The rule is record-first: the golden recorded against the old code is the only parity proof, so a site the recorder cannot mount cannot be recorded, and unrecorded sites do not migrate. Stated once in the list's header. codex-reset-credit.ts loads fine under the module loader; probed it, and its attempt-journal access throws on async-storage at call time before the send, with no guard to skip it. The old comment described it as a storage read around the send. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the status.get policies without counting them "the fourth policy on this method", "the first of two" and "the second of two" were already wrong after round 1 folded the files family's status read into the tasks one. Each comment now states its own invariant, which no later policy can invalidate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): stop the activation scan claiming to pin the wire `expect(operations).toContain("method: 'worktree.activate'")` passes whichever operation carries that method, so it survives swapping worktreePinWrite's and worktreeActivate's methods. tsc and the host-worktree-actions-pin-open-delete golden both fail on that swap; the scan keeps only what it can prove, which is that the callback sends through worktreeActivate with the two flags. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(agent-status): isolate legacy status ingress behind one admission point (#20716) * refactor(agent-status): isolate legacy status ingress * fix(agent-hooks): move advertised-capability source onto the ingest envelope ingestRemote() gained a third positional argument in this PR (advertisedAgentStatusCapabilities) to satisfy a new ratchet requiring every legacy-ingress call site to name its capability source. Both production callers pass the same constant every time, so the argument carries zero runtime information — but Vitest's toHaveBeenCalledWith matches argument count exactly, so the pre-existing SSH relay integration test (which asserts a 2-argument call) started failing even though nothing about the actual admission decision changed. Capabilities are a property of the producing peer/connection, not an orthogonal call parameter, so move the field onto the envelope object instead of adding a third positional argument: ingestRemote reads envelope.advertisedAgentStatusCapabilities (defaulting to the unadvertised-legacy-peer set), and both call sites stamp the constant onto their envelope literal. Call arity stays at two arguments, so the pre-existing evidence test needs no change. The envelope never crosses the wire in either caller: SSH rebuilds it field-by-field from the RPC params, and the WSL path copies (never mutates) the wire-deserialized notification before stamping the field on, so this is purely an internal main-process shape change. Also strengthens the ingress ratchet test that required this: it previously only checked that the capability constant's name appeared somewhere in each caller's source, which a stray unused import could satisfy. It now asserts the actual `advertisedAgentStatusCapabilities: AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES` key:value binding is present. * Update README downloads badge * refactor(mobile): send the github.* PR surface and the diff-review loaders through typed RpcOperations (#20668) * test(mobile): record main's github.* PR and diff-review loaders before migrating them Scenarios and goldens for the step-4 `src/session/` first half, recorded against main's unmigrated product code so the migration that follows has a frozen parity oracle instead of an assertion. - 20 scenarios over seven new families: the seven `github.*` PR reads, the twelve PR mutations split by their three reply contracts (`{ok}` envelope, bare boolean, slug-addressed comment edit), the triage createTerminal+send launch, the PR branch-context chain and the review screen's three loaders. - Two new sender-style mount adapters. Both mount exported async functions taking a client, so no React host is needed and the recorded state is each wrapper's own outcome. - 50 new goldens: 20 pilot, 30 reply-matrix sites. `recorderSha256` moved on all 153 existing goldens because the adapters are in the whole-recorder digest; no other line in any of them changed. Text diffs are deliberately unscripted: highlighting one reaches `lowlight`, which the module loader refuses as an unspecified native dependency. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the github.* PR surface and the review loaders through RpcOperation The step-4 first half for `src/session/`: eight files, 38 references to the raw request port, all replaced with declared operations. No behaviour change — the 50 goldens recorded in the previous commit do not move, which is the claim. - 21 operations over 21 methods. The seven PR reads keep their defensive parsers as readers; the ten status-envelope mutations share one reader because the `{ok, error}` convention is one host convention, not ten; the two bare-boolean mutations read the payload unchecked because `=== true` is the caller's confirmation rule. - Four second readers, each justified in place: git.status and git.branchCompare for the PR branch context (a refusal costs a fallback, not the screen), git.branchCompare and git.branchDiff for review (the projection is not a superset of the verbatim payload), and worktree.show for the review notes the summary reader drops. - Every failure text is preserved, including the two main kept apart: a refusal with no message falls back to the screen's copy, a transport drop with no message surfaces its empty message verbatim. `sendRaw`'s callers replaced theirs a second time, so those fall back on both paths. - No retry, and no operation reads a dropped reply as a failed mutation: the rejection reaches each wrapper's catch as the original object. - `github-pr-mutations.ts` split along the action/comment seam it already had in its consumers, so no file needs a max-lines bump. Inventory: src/session/ 47 files / 114 references -> 39 / 76. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the review snapshot answering its notes leg first The barrier mutation census found one survivor: moving `reviewWorktreeMetadataRead.interpret` inside the `Promise.all` in `loadMobileDiffReviewSnapshot` changed nothing any golden observed. The base scenario answers the branch-base legs before the notes leg, so by the time the notes reply lands the compare leg has already sent `git.branchCompare` and the two orders record the same sender list. This scenario answers the notes leg first, while the compare leg is still resolving its base ref, and checkpoints before the rest. At that checkpoint the barrier is the whole difference: the correct order has nothing settled, the early interpretation has already rejected the action. The mutation now fails it. Recorded from a detached checkout of the previous commit, which carries main's unmigrated product code with this branch's recorder over it, so the parity claim stays non-circular. One new golden; no existing golden moved, because the family base is unchanged and `scenarioSha256` is per golden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): bind one git.status projection reader, not a copy per domain The branch-context read declared its own `statusProjectionReader` with the same parser, the same 'normalized-status' variant and the same empty salvage as source-control's `gitStatusProjectionReader`, while its doc block claimed "one reader serves both". Export the source-control reader and bind it here so the claim is true; the doc now names the reader and keeps the part that is actually different, which is what a refusal means on each policy. No wire change and no golden moves: the reader is the same function value the copy computed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): undo the github-pr-mutations split, which max-lines no longer forces The split was made when the migrated file measured 319 lines. It does not any more: `sendRaw`, `sendGithubPrMutation` and `extractMutationError` moved to github-pr-mutation-outcome.ts and the prRepo/headSha allow-lists to github-pr-repo-slug.ts, so the merged file is 293 lines against the 300 limit and oxlint is clean. Nothing imported github-pr-comment-mutations directly — every consumer went through the re-export hub in github-pr-mutations — so the seam bought a reader one extra file to open and nothing else. Merge it back and drop the hub. Product-only: same wrappers, same params, same settle shapes, no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): one settleable-operation type for the PR reads and mutations `GitHubPrMutationOperation` and the private `GitHubPrReadOperation` declared the same two members for the same reason: a settle shape needs a bound operation's method and its interpret, nothing else. Keep one, `GitHubPrSettleableOperation`, and import it into the read settle. `extractMutationError` goes back to private, as it was on main; it never had an importer outside its own file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): drop the key-order claim from the PR param builder The oracle does not observe param key order: `captureValue` in recording-values.ts sorts keys, and no golden carries a raw frame string, so "the sender recordings pin the bytes" was not a fact the evidence supports. The assertion stays for the reason already in the doc — the builder is method-generic and returns a record. `GitHubPrParamOptions` goes back to private; nothing outside the module names it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read the bare-boolean mutations with the shared unchecked reader `mutationConfirmationReader` spelled out what `rpcUncheckedPayloadReader` already returns, under the same 'pr-mutation-confirmation' variant that eleven other operations in this tree get from the helper. Same function value, same variant, so no golden moves. The comment explaining why the payload is left unread stays. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): one RpcOperationSender for both domains, not one alias each `MobileSessionRpcSender` and `MobileSourceControlRpcSender` were the same type with the same doc, each derived from whichever operation its domain happened to own. Replace both with `RpcOperationSender` in transport, derived from `settingsRead` there, and name it for what it is: what a bound operation needs to send with. Still derived rather than restated, so no module names the raw request port to accept a client; the port inventory and its ratchet are untouched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): point the moved PR and diff-review adapters at the seam and register them The merge commit carried the two adapter files into adapters/ with their old specifiers and left the register untouched, so this completes the move: the relative imports climb one more level, and both modules are registered in adapters/mounted-operation-modules.ts as identifiers imported from their own source, which is what adapter-seam.test.ts checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the session goldens against #20662's adapter seam The merge brought #20568's per-golden scenario digest and #20662's per-golden adapter digest, so the 51 goldens this PR owns move on four header fields and nothing else: baseline, goldenFormatVersion, recorderSha256, and the newly added adapterSha256. No recorded byte outside those headers changed. baseline stays at main's own pinc6a7216984rather than moving to6a11a0b8e6. Repinning rewrites the baseline line in all 208 goldens main owns, which this branch must leave byte-identical. Recording at either commit produces identical bytes everywhere except that one line, so the pin costs no coverage. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the transport pairing and status domain through typed RpcOperations (#20667) * test(mobile): record the transport pairing and status domain against main Adds seven recording families for `mobile/src/transport/`, recorded from main's unmigrated product code before any refactor: the protocol-gate hook, the retrying capability probe, the pairing candidate race, credential rotation, direct-to-relay upgrade, startup pairing recovery and first pairing. The relay modules build their `defaultDependencies` at module scope, so merely referencing `Platform.OS` or a storage-backed loader threw before an adapter could override it. `native-mounting-substitutes.ts` separates reference from use: react, zod and @noble/hashes are the real libraries, expo-crypto routes through the Web Crypto the scheduler already pins, and the two secret stores throw when called. `baseline` repins tofc525c355dbecause main's tree no longer matches the pinned50e752fc66. All 241 goldens re-recorded; main's 208 move only `baseline` and `recorderSha256`, and no `scenarioSha256` or recording body moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the transport pairing and status domain through typed RpcOperations Migrates 11 of the 18 raw-port references in `mobile/src/transport/`: the protocol gate, the runtime capability probe, the pairing candidate race, credential rotation, the direct-to-relay upgrade, startup pairing recovery and first pairing. Three operations over three methods, zero new acceptance policies. `status.get` gains a third named policy — `status.transport-probe-or-skip` — because all three transport callers treat a refusal as an absent answer, which is the Tasks create-drawer policy but not the Tasks hydration one; transport cannot import tasks, and the name is what a decode failure reports. `pairing.provisionRelay` and `pairing.getEndpoints` are `require-result-or-throw`, which reuses the same `code: message` text the four call sites each spelled by hand. Three sites read the raw envelope for `method_not_found` before interpreting, because an unknown method means "this build has no relay" rather than "the install failed". Reply parsing stays at the call sites: the readers are unchecked and the zod contract schemas run where they ran before. No wire change and no behaviour change: all 241 goldens replay green and this commit touches none of them. Two files stay on the raw port and now carry their own reason in the inventory. `pairing-relay-candidate.ts` decorates a PairingCandidateClient with director recovery, so it implements the port rather than calling it; its one chosen method string now comes from hostStatusProbe. `mobile-runtime-capability-negotiation.ts` sends over the physical clients' pre-`connected` authenticated path, which no recording can reach. `runtime-capability-probe.ts` drops to its parameter type alone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the capability probe's cutover re-ask against the frozen baseline The reply matrix drives every scripted reply, so it already covers a transport rejection at each migrated site. It cannot cover the one signal the probe reads out of a rejection: `isLogicalClientCutoverError` chooses a 250 ms re-ask over the 1 s failure backoff, and no golden made that choice observable. `transport-capability-probe-cutover-reasks-fast` migrates the logical client mid-probe and binds the replacement request at exactly 250 ms, so a re-ask moved in either direction fails the binding rather than recording a different number. The recorded rejection carries `LogicalClientCutoverError`, its `Connection closed` cause and `isRpcDeliveryUnknown`. Recorded from a `git archive` of the pinned baseline with this branch's recorder laid over it, per the recorder README: the migrated tree can no longer satisfy the fence. The same run reproduced the other 241 goldens byte-for-byte. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read the pairing sends top to bottom Five pairing sites nested `parse(interpret(await request(...)))` three deep with the await innermost, so the send was the last thing a reader found. Bind the reply first and interpret it on its own line, which is the shape main had before the migration. No behaviour change: the request still settles before interpretation and the barrier is unmoved, so no golden shifts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make an unlisted native member throw instead of recording undefined Three substitutes were plain object literals standing in for whole modules, so any member the table did not list read as `undefined` rather than failing — the opposite of what the file's own default proxy does, and a silent one: `react-native` alone is requested 354 times across the goldens, and `platformSelect`, `view` and `styleSheet` all read undefined. A recording that reaches an unsubstituted native member is not evidence of anything, because the product on a device would call it. Also drops the `expo-secure-store` substitute, which no recording requests, and corrects the doc's "fails loudly" claim: the throw is real, but `host-app-version-store.ts` catches it and degrades to its unread state, which is what it does on a device too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep one credential hash and only the fixtures someone reads Nine fixture constants and `relayEndpoint` were exported with no reader outside their own module, and `credentialHash` was copied verbatim into both relay adapter modules. The adapter seam forbids one module under `adapters/` from importing a sibling, so the shared form has to live with the fixtures both already import. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden at main's tip Repins `baseline` to6a11a0b8e6and records all 242 from a detached worktree of that commit with this branch's recorder overlaid, per the README's migration-branch procedure. Two header fields move and nothing else: `baseline`, and `recorderSha256` for the 208 goldens main also carries, because this branch adds `native-mounting-substitutes.ts` and `relay-pairing-fixtures.ts` to the engine and teaches the loader to consult the first. Against the pre-merge tree every one of the 242 is header-only, so no recorded byte moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): let the interop marker through the native substitute proxy `import * as ExpoCrypto from 'expo-crypto'` transpiles to an interop helper that reads `__esModule` before copying members, so throwing on it fails the module system's own probe rather than an unsubstituted API read. Four relay families could not record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the substitute through Reflect instead of a cast The changed-code casting gate rejects `key as string`, and the trap has a typed read that needs no assertion. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record for the substitute's typed read `recorderSha256` only; every recorded byte is unchanged from the previous re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): restore main's baseline pin and re-record The merge brought main's pinc6a7216984. Repinning it to the branch's own merge-base rewrote the `baseline` header of all 208 pre-existing goldens for no behavioural reason, so put main's pin back and re-record at that commit through a detached baseline worktree with this branch's recorder overlaid. Only `baseline` moved in all 242 goldens; no recorded frame, settlement or sender line changed. Against origin/main the 208 pre-existing goldens now differ in `recorderSha256` alone, which this branch's two added engine files force. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): let a default import of the secret store reach its members The store substitute answered every key with a throwing function, including `__esModule`. TypeScript's `__importDefault` reads that marker and, finding it truthy, binds the default import to the trap's own function instead of the module record, so all five consumers saw `AsyncStorage.getItem` as undefined. A recording that reached `host-app-version-store.ts` failed with `TypeError: AsyncStorage.getItem is not a function` rather than the named `Native store reached during recording: ….getItem`, and an `await import()` of the store rejected on `.then` for the same reason. Both traps now leave the marker undefined, and the doc states that invariant. Also drop `recordingRandomBytes`: its body and the `expo-crypto` substitute's are the same call into the seeded `getRandomValues`, and both relay entry points already default `randomBytes` to `ExpoCrypto.getRandomBytes`, so the two injections were passing the default back to itself. Its orphaned doc comment, left stacked above `credentialHash` by the dedupe commit, goes with it. The new suite does not record, so `recorderSha256` does not pin it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record for the secret-store and randomBytes fixes `recorderSha256` moved in all 242 goldens, and `adapterSha256` in the 10 `relay.credential-rotation` and `relay.direct-upgrade` goldens whose adapter stopped injecting `randomBytes`. Nothing else moved: no recorded frame, settlement, effect or sender line differs, which is the claim that the default the adapter was passing back to itself and the substitute it resolved to were always the same function. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): adopt this branch's recorder digest in main's new goldens Only the 47 goldens #20705 added moved, and only on `recorderSha256`: they carry main's engine digest, and this branch adds two files to the engine. The other 242 came back byte-identical, so the merge changed nothing any of them observed. No recorded frame, settlement, effect or sender line differs anywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the pairing race's decision and name its scenarios truthfully The race adapter returned the raw `PairingCandidate`, whose `client` is a live object the recorder cannot serialize, so `captureValue` threw and every pairing-race golden baked an `Unsupported observation: function` unhandled-rejection effect and left the race settlement `pending`. It now settles on `winner.path`, which is the entire decision, and rethrows so the both-refused rejection keeps its identity. `transport-pairing-race-direct-wins` also did not record a tie. The runner flushes after every step, so the two completions can never land in one microtask and the scenario only ever exercised relay completing first. Rather than change the engine to script a simultaneous delivery, the scenario and its checkpoint are renamed to what they record, and a mirror scenario completes direct first. The two matrix checkpoints inherit the base's name, so they follow. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the merge's headers and the repaired pairing race 66 goldens moved, in four groups: - 51 from #20668, `recorderSha256` only and zero non-header lines: they carry main's engine digest and adopt this branch's. - 10 other `transport-status` goldens, `adapterSha256` only and zero non-header lines: their adapter module changed, their recordings did not. - `transport-pairing-race-relay-wins-when-direct-refused` and the two `matrix-transport.pairing-race-*` goldens: the race settlement is now fulfilled with `'relay'`/`'direct'` instead of pending, and the `Unsupported observation: function` unhandled-rejection effect is gone. The two matrix goldens also move `scenarioSha256`, having inherited the base scenario's renamed checkpoint. - `transport-pairing-race-direct-wins` is renamed to `transport-pairing-race-relay-completes-first`, and `transport-pairing-race-direct-completes-first` is new. `transport-pairing-race-both-refused` did not move: its race rejects, and a rejection was always serializable. `recorderSha256` moved nowhere except in those 51, which is the proof the engine is untouched by this step. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the module the throwing-on-call substitute stands in for The paragraph called it "the secret store", which is `expo-secure-store` — not in the table, and handled by the loader's throw-on-read default. The substitute that answers with throwing functions is async storage. This is an engine file, so the next commit re-digests every golden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-digest every golden for the substitutes doc reword `recorderSha256` in all 341 and nothing else: the reworded paragraph is a comment in an engine file, so it moves the digest without moving a recording. No frame, settlement, effect or sender line differs anywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the task provider, detail and board domains through typed RpcOperations (#20685) * test(mobile): record main's task provider item, detail and board RPC behaviour 35 scenarios over 22 of the 25 files left in src/tasks/, recorded from main so the step-4 migration of the provider half has a frozen answer to compare against. Every one of the 70 references this branch will migrate reaches a recorded wire here, which is the check the workspace-creation half added after it lost three sites to fixtures that short-circuited before the call. Scenario params are observed, not written: a generator drove each adapter with nothing answered, read the projected sender calls back, and emitted the completion steps from them, so no `params` in the manifest is a guess about what the screen sends. Five adapter modules, split the way the screens are: one item's reads, the list and composer, the item mutations, the board's reads and the board's row mutations. `mountModelHook` holds the mount/dispatch/project boilerplate these twenty-two hooks share, so each adapter is only its fixture, its actions and its projection. Two fixture modules hold the task items and the project rows, shared so the same pull request looks the same to the comment hook, the merge hook and the checks hook — which is what makes their recordings comparable. `baseline` moves from50e752fc66(#20562) tofc525c355d(#20568), the commit this records from. The pinned baseline had drifted from main again when the workspace-creation half landed, and recording refuses to run against a tree that does not match it. This is main's product source, not the branch's: no product file changes in this commit. The 208 existing goldens change header-only — `baseline` and `recorderSha256`, the latter because any adapter is inside the recorder digest. Verified field by field: nothing else moved on any of the 208. Goldens: 208 -> 317, 3.8M -> 7.7M. 74 new matrix sites over 35 new families. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the task provider, detail and board domains through typed RpcOperations 22 of src/tasks/'s 25 remaining raw-port files now send through a declared operation instead of the raw request port: 70 references to 0, leaving 3 files and 3 references. No golden moved — `git show --stat` on this commit touches nothing under mobile/rpc-foundation/, which is the parity claim, and the 317 goldens recorded in the previous commit all pass against this tree. 56 operations over 58 methods, in five modules named for what they send: one item's detail reads, the list's provider loads, item comments and replies, item state/merge/check writes, and the GitHub Projects board. Five more operations are reused from the workspace-creation half rather than redeclared, because the list asks github.listWorkItems, gitlab.listWorkItems, linear.searchIssues, linear.listIssues and settings.update with the same acceptance the Smart picker does. Three methods carry two policies each, and all three pairs are named. `linear.status`: task hydration cannot list without the workspace and surfaces the host's message, the home probe degrades to "not connected". `linear.listTeams`: hydration reconciles a saved selection and needs it, the composer's picker just empties. `github.repoSlug`: the Projects board must tell "no slug" from "the ask failed" and caches the failure for retry, the paste lookup caches a refusal as "no slug" and carries on. Each pair shares one reader, so no method has two. No new acceptance policy. Ten sites picked a method with a ternary. Nine were a literal pair — a provider or an item type choosing between two methods — and each now selects between two operations instead, which also types each arm's params separately. Two of those were listed as unmigratable `{ method, params }` multiplexers: `use-mobile-tasks-project-file-merge-actions.tsx` and `use-mobile-tasks-hosted-metadata-actions.tsx` both assign `method` and `params` from local ternaries over `item.source.type` in the same function, not from a step a picker hands them, so both migrated and both reach zero. The Linear detail barrier keeps raw requests inside its `Promise.all`. main's group rejects as soon as one leg's transport does, and interpreting only after both settled is what lets the comments rejection win over the issue refusal — the b3 seed. `startRpcOperation` would wait for the slower peer. Every loading hook's `stale` or generation guard stays where it was, between the request and the state commit. Two preserved oddities, both recorded rather than repaired: - `gitlab.todos` keeps its payload spelled `response.result`. A reply that is neither an array nor nullish crashes in `.map`, and the message the screen shows is that expression's source text; renaming the local moved a golden, which is how this was found. - `github.listWorkItems` keeps sending `before`. The list's pagination cursor is not in that method's params schema, so the host has always dropped it and mobile's GitHub "load more" re-asks for the same page. Sent verbatim with a cast; making the host honour the cursor is a product fix with its own recording. Worth a ticket. `github-project-host-routing-source.test.ts` pinned method literals that have moved into the operation modules. It now pins the same guarantee in two halves — the board site carries the host or the row's `prRepo`, and the named operation still sends that method — so neither half can drift alone. The board's issue/PR update repeats its params rather than hoisting them, so each send textually carries its own host, which is what that test reads. The Mobile Tasks source-parity hashes move for the same reason the workspace half's did. The diff is evidence rather than a re-pin: `semantics` is a pure deletion, 148 lines out and none in — 70 `rpc:` call signatures, 75 method literals over 58 methods, and three duplicated `item.source.type` comparisons that only existed because one `sendRequest` had to pick both a method and a matching params shape from the same test. Statement, declaration, render and style counts are unchanged, and the render, declaration and style hashes are byte-identical. `b3: kills order` fails at this commit and only this commit. Its anchor names the send this migration rewrote, so it matches zero sites; the next commit rehomes it at the same defect and re-digests. Every other test passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): rehome the b3 barrier mutant and name the recorder's fixture cast The `order` mutant anchors the defect that the b3 seed exists to record: interpreting the issue leg inside the request chain instead of at the barrier, so the group rejects early and the sibling comment request is abandoned out of order. Its anchor named `client.sendRequest( 'linear.issueComments'`, which the previous commit rewrote, so it matched zero sites. Rehomed at the same defect in its new shape — a `.then` that interprets inside the chain — per the recording README, rather than deleted. It still kills, and for the same reason: the recorded error becomes the issue refusal instead of the comments transport drop. The adapters also stop casting per action. Sixty-five `as never` casts became one named `mountFixture`, which says once why these fixtures are deliberately partial: they carry only the members the mounted hook reads, and completing them into full domain objects would invent data no scenario observes. `check:code-quality:changed` is clean on all 39 changed files. Both edits are inside `recorderSha256`, so all 317 goldens carry a new digest and nothing else — verified field by field, `recorderSha256` is the only key that moved on any of them, and no golden was added or removed. Recorded from the pinned baselinefc525c355din a separate worktree with this branch's recorder laid over it, so the goldens stay attributable to main's product source rather than to the migration. The suite is green here with the migrated source, which is what makes the previous commit's "no golden moved" claim mean something. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record a Linear detail whose issue leg is answered The acceptance census found one operation whose declared policy no golden observed: swapping `linear.issueComments` from skip-on-refusal to throw-on-refusal survived every test. The reason is the b3 seed. Its scenario refuses `linear.getIssue`, and the detail hook interprets the issue leg first, so the issue error is raised before the comment leg's policy is consulted — and the reply matrix drives one site per golden against the base scenario's other replies, so every partition at `linear.issueComments` still had a refused issue beside it. The comment leg's acceptance was unreachable, not merely untested. `tasks.item-detail-linear` mounts the same hook with the issue answered. Its matrix drives both legs with the other one fulfilled, which is what makes "a refused comment list leaves the sheet with no comments" an observation rather than a claim. The policy swap now kills it on two goldens. b3 is untouched: it still pins the defect it was written for. Goldens: three added, and the other 317 carry a new `recorderSha256` because the adapter gained a registration. Nothing else moved on any of them. Recorded from the pinned baselinefc525c355dwith this branch's recorder, as before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the two task parity hashes the import-form fix moved The migration commit pinned the hook and statement hashes before `oxlint` asked five task files to write `import type { X }` rather than `import { type X }`. Both readers walk import statements, so both hashes moved; the fix landed after the hashes and the suite was left red. Nothing observable changed. Hook, statement, declaration, render and style counts are all unchanged, and the declaration, render, style and `semantics` hashes are byte-identical — `semantics` is still the same pure 148-line deletion against main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the real second consumer and the real reason for deferred interpretation Two comments from the migration described code that does not exist. The `linear.status` note credited the skip policy to the home screen. The home screen does send `linear.status`, but through an unmigrated single-flight request in mobile-home-host-requests.ts, so it is not the other policy at all. The only consumer of `taskLinearStatusRead` is the Tasks runtime hydration hook, which is what actually treats an unanswered probe as "not connected". Naming the wrong caller makes the two-policy claim unverifiable for the next reader. The Linear detail group said "raw requests inside the group" while the code calls `linearIssueRead.request`. The requests are operations; what stays inside the group is the deferred interpretation. The reason is unchanged and still the point: this `Promise.all` rejects as soon as one leg's transport does, and interpreting only after both settled is what keeps the issue error winning over the comments error. Comment-only, so no golden and no recorder file moves. The two parity hashes do move, because `normalized()` captures a statement's full text and these comments sit inside the effect callbacks it captures; both element counts are unchanged at 350 and 417, which is what shows nothing structural shifted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): move the task provider adapters into the per-golden adapter seam #20662 pins each golden to the one adapter module it was recorded through, and pins the engine directory to every golden. This branch's adapters sat in the engine directory, so leaving them there would have re-digested all 208 goldens main already has. They move into `adapters/` and register themselves, and the engine directory is now byte-identical to main's: `recorderSha256` computes to 2e90933db32e, which is the value main's goldens already pin. The seam forbids an adapter importing another file in the directory, and the register test requires every file there to be a registered module, so the shared fixtures and the shared mount helper could not follow the adapters in. Each module now carries the fixtures it actually mounts and its own copy of `mountModelHook`, which is how main's nine modules are already written. That is real duplication, about 55 lines of helper per module, and it is the price of a golden naming one file as its provenance. Five modules became eleven for the same reason: a self-contained module carrying its own fixtures crosses 300 lines, so each split at a hook boundary rather than taking a `max-lines` bump. One behaviour note. `task-mount-adapters.ts` mounts `use-mobile-tasks-item-detail-loading.tsx` for its own family, and this branch mounts the same hook for three more. With a loader per module, both modules' loaders applied the `order` mutant anchored in that file and `assertMutationApplied` saw two applications where it requires one. Deferring this module's load to mount time fixes it, and matches how `task-mount-adapters.ts` already loads it. The general hazard is worth an engine guard and is reported separately: any future module that eagerly loads a mutant-anchored file breaks that count, and nothing fails until someone runs the mutants. Goldens are untouched here. They still carry the pre-merge header and the re-record is the last commit in this sequence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the recorder's fixture helper as a checked subset of what it stands in for `mountFixture<T>(value: unknown): T` accepted anything, which is what let three fixtures record a value the product cannot produce. It now takes `PartialRecorderFixture<NoInfer<T>>`: every member optional at every depth, but no member the real type lacks and none with the wrong type. `NoInfer` is what makes the parameter's type the target rather than the fixture's own. The type lives outside `rpc-recording/` because every golden pins that directory and the helper is copied per adapter module. A type cannot change a recording, so keeping it out is what stops eleven copies of a recursive conditional type from existing. Two deliberate allowances, both stated in the type. Functions pass through whole, since a stub with optional parameters is one the hook cannot call. And a member may be `null` where the product type says only optional, because these fixtures stand in for JSON the host sent and JSON spells an absent object `null`; four Linear fixtures rely on that, and rewriting them to `undefined` would move them away from what a host sends rather than towards it. The two `mountFixture(model.client)` calls become `context.client`, which is typed `RpcClient` and needs no cast at all. The model holds that same object under an `unknown` fixture record, and `observableModel` returns it unwrapped, so this is the same client read from the side that knows its type. No fixture value changes here, so this moves nothing a golden records. The three divergences the signature exposes are the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fix the three fixtures that recorded values the product cannot produce Each of these was caught by the checked `mountFixture` signature in the previous commit, and each made a golden record a path no user can reach. Fixture changes, so the goldens they drive move at the re-record, and each moved golden is a claim listed there. The single-select field mutation sent `{ singleSelectOptionId: 'option-1' }`, which is not a member of `GitHubProjectFieldMutationValue`. `optimisticProjectFieldValue` fell through to the text fallback, so the golden recorded `{kind: 'text', text: ''}` for a SINGLE_SELECT field and the single-select branch was never exercised. The value is now `{kind: 'single-select', optionId: 'option-1'}`. That alone was not enough: the branch also tests `field.kind`, and `STATUS_FIELD` carried only `dataType`, so `kind` was undefined and the fallback still won. The field now carries its discriminant, and the option it selects is present in `options`, because a board that loaded a single-select field has its options and an empty list contradicts a user picking one. Without it the optimistic value would record the not-found `'Selected'` / `'GRAY'` fallback instead of the option's own name and colour. `ownerType` was `'ORGANIZATION'` against `'organization' | 'user'`. The value reaches wire params unchanged, so no branch was skipped, but six goldens pinned an owner type the product cannot send, and `githubProjectIdentityKey` interpolates that field without normalising it while it does lowercase `owner` and `host` — so the recorded settings key was one the product cannot produce either. The same file already spelled it `'organization'` in one of three places, which is how it went unnoticed. The issue-type fixture was missing `color` and `description`, both of which `GitHubIssueType` requires and neither of which is optional. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): move the fixture-shape type inside the recorder, where recording can see it `mobile/scripts/rpc-recording.mts` fences `mobile/src` against the pinned baseline and exempts only `mobile/src/test-support/rpc-recording`, by tracked diff and by an untracked-file check. A type file one directory up therefore fails recording outright as an unpinned product source, which is not a judgement about the type, just where the fence is drawn. So it lives in the engine directory. That has a cost worth naming: `recorderSha256` covers the engine, so all 208 goldens this branch shares with main now carry a new digest. That is the one thing #20662 removed and this is the case it cannot remove — a genuinely shared recorder input has nowhere to go that is both inside the fence and outside the whole-directory digest. `adapters/` is not available: its seam test requires every file there to be a registered module, and forbids one module importing another. The alternative was a copy of the type in each of eleven modules, which would also have forced a twelfth split, since the conversation module is already at 295 of its 300 lines. One shared type and one re-digest is the cheaper trade, and the re-digest is a single header line per golden with no recorded value moving. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden from the pinned baseline with the merged recorder Recorded fromc6a7216984in a detached worktree with this branch's recorder laid over it, per the README's migration-branch procedure, because this branch's product tree is migrated and recording in place would make the parity claim circular. Two adapter loads became lazy first, and that was not cosmetic. `golden-header-digest.test.ts` builds a temp tree holding only the product files one family needs, then calls `adapterSourceByOperation`, which invokes every registered module's `mounts`. Eight of this branch's factory functions loaded their hook while the table was being built, so they tried to read files that tree does not have and five engine tests failed. The same eager load made `assertMutationApplied` count two applications for the two mutants whose anchor file another module also mounts. Every factory now loads inside the mount, which is how main's modules were already written. Header movement, all 208 goldens this branch shares with main: `recorderSha256` only, from 2e90933db32e to 202244bdc6c5. Zero non-header lines. The cause is one added engine file, the fixture-shape type, explained in its own commit. Ten goldens moved beyond the header, all in the two families whose fixtures were corrected, and no family outside them moved: tk-project-row-fields and its updateitemfield, clearitemfield and updateissuetypebyslug matrices send `value: {kind, optionId}` where they sent `{singleSelectOptionId}`, which the host's `graphqlValueForFieldMutation` would have rejected as an unknown kind, and now record a single-select field value where they recorded the text fallback. The field carries its `kind` discriminant and its option, so the recorded value carries the option's name and colour. The issue-type row gains `color` and `description`, both required and neither on the wire. tk-project-board-load and its listaccessible, listviews, viewtable and resolveref matrices spell `ownerType` `organization`. The host derives that value from GraphQL `__typename` and only ever lowercases it, so the uppercase form was unreachable in both the reply and the params. `baseline` also moves on this branch's own goldens, fromfc525c355dto main'sc6a7216984, which the merge commit explains. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin Project host routing to the declared method, not the identifier prefix The guard matched `githubProject*.request(` sites, so an operation renamed off that prefix left the prefix match empty and the host could go with the rename: renaming `githubProjectFieldUpdate` to `projectFieldUpdate` at its definition and its one call site and deleting `host: activeGitHubProjectHost` from the `github.project.updateItemField` request kept all three tests and `tsc` green, and `host` is optional in the params type so nothing else caught it. Derive the list from the board module by the method each operation declares instead, and scan every product file under `mobile/src` rather than a hand-listed eight, so a site that moves stays covered. Coverage goes from 13 matched sites to 17 across all 16 declared operations, because the old regex also missed the `op\n .request(` form four of them use. An operation that stops being requested at all now fails too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): share the recorder's fixture helper instead of copying it into eight adapters The seam forbids one adapter importing another, not an adapter importing the engine, and the adapters already take `hookMount` and `observableModel` from there. So the eight byte-identical copies of `mountFixture` bought nothing: eight doc comments and eight cast suppressions for one four-line function that has no per-domain part. Export it from `recorder-fixture-shape.ts`, next to the type it checks against, and leave one suppression instead of nine. `adapter-seam.test.ts` 7/7 and `pnpm --dir mobile typecheck` stay clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fence what the recorder fixture shape accepts, and drop the one branch that is dead Review read the function branch and the `ReadonlySet | ReadonlyMap | Date` branch as dead because typecheck stays at zero without them. Zero was the wrong oracle: no fixture in the tree carries a callback, a set or a map, so nothing exercised them. Dropping both lets a `3` stand in for a callback the mounted hook will invoke, and lets `{}` stand in for a set. So pin them instead of asserting them. `recorder-fixture-shape-compile-fence.ts` is a non-test file, which is the only kind `pnpm --dir mobile typecheck` covers, and each case fails as an unused `@ts-expect-error` if the branch it stands on is removed: the callback case on the function branch, the set and map cases on the second branch, and the accepted case on `| null`, whose removal is 3 errors in the adapters. `Date` really was dead and is gone: its members are all methods, so the function branch already refuses a structural stand-in for it, and the fence keeps that honest. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fail an adapter that loads product source while its table is built Nothing caught a `modules.load` hoisted out of `useHook` into the table literal, and the two things it breaks both report as engine faults far from the edit: a mutant anchored in a file two families share gets applied twice and `assertMutationApplied` reports the wrong count, and `golden-header-digest.test.ts` builds tables in a tree holding one family's files and throws `Module not found` for every other family. This PR hit both while splitting the task adapters. Build every registered module's table with a loader whose `load` throws, and assert none did. Hoisting the `use-mobile-tasks-item-detail-loading` load in `task-item-detail-mount-adapters.ts` fails it by name; `adapter-seam.test.ts` builds the same tables with a real loader and stays green, which is why it never saw this. The suite records nothing, so `recorderSha256` excludes it and no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the fixture-shape fence in the file it fences, not beside it A compile fence as its own file is an orphan the seam already rejects: `recorderSha256` pins every file in the recorder directory, and `mutant-seam.test.ts` requires each pinned file to be reachable from a recording driver, because anything pinned and unreachable re-digests all 320 goldens while being unable to move one. The separate file failed that check by name. Fold the cases into `recorder-fixture-shape.ts`, which the adapters already import, and drop the directory literal from the comment so the seam's name scan stays clean. Removing a branch still fails: function branch 2 errors, set-and-map branch 2, `| null` 4. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden for the shared fixture helper Recorded from the pinned baselinec6a7216984with this branch's recorder laid over it, per the README's migration-branch procedure. Two header fields move and nothing else does: `recorderSha256` on all 320, because the engine now carries `mountFixture` and the cases that fence its type, and `adapterSha256` on the 93 goldens recorded through the eight adapters that gave that helper up. Non-header lines changed: 0. The candidate suite is 387 passed, 3 skipped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden after merging main's recorder Recorded from the pinned baselinec6a7216984with the merged recorder laid over it, per the README's migration-branch procedure. One header field moves on all 453 goldens and nothing else does: `recorderSha256`, because this branch adds `recorder-fixture-shape.ts` to the engine that main's copy does not have. `adapterSha256` holds everywhere, since no adapter changed in the merge. Non-header lines changed: 0. The candidate suite is 523 passed, 3 skipped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the dictation, terminal, notification and browser domains through typed RpcOperations (#20702) * refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's `recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its merge with main conflicted on that one line in 153 files; every future domain PR would collide with every other in flight the same way. Split the directory at a real seam instead of a filename convention: `adapters/` holds one module per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers the engine only. A new `adapterSha256` covers the source of the module that mounts each operation a golden's scenarios drive, read off the same `mounts` calls that build the table the recording runs against, so the pin cannot name a file the runner did not use. Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file, and an adapter importing a sibling each fail. The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new header field; the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the split recorder/adapter digest Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers `adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and recording ran against the same pinned product tree. git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l 0 The seven `adapterSha256` values partition the 153 goldens by the module each was recorded through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory, 9 tasks, 8 workspace settings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop pinning goldens to recorder inputs no recording can read The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the per-family mutant registry beside it, and the probe-hole witness. None can change a recording -- the loader consults a mutant only when a mutant test asks for one, and no suite but the two recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have and charged every domain a full re-record for it. `mutants/` now holds the table, the registry, the reference states, the mutant suites and the probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts` checks exactly that, and fails if an engine file names the directory or anything outside imports from it. `recorderSha256` also pins only the suites in `recording-drivers.ts`, which `scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or writes one to a scratch directory, is no longer provenance for a recorded file. `OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden. Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to `root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed anything. Each call now spells the root differently. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the mutant and driver exclusions Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153 because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that module now carries its own exposure declaration. git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256)":' | wc -l 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the preferences actions the merge resolution dropped #20568 added `resume` and `trust` actions to the `settings.task-preferences` adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the `pilot-mount-adapters.ts` conflict in favour of the registry merge silently discarded them and `tw-task-preferences-resume-write` failed to record at all ("Missing or completed request: ui.set#1"). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens at main's tip after the merge All 208 goldens, header-only. `baseline` moves from50e752fc66to main's tipc6a7216984, `goldenFormatVersion` from 4 to 5, `recorderSha256` to the value of the engine with `adapters/` and `mutants/` carved out, and `adapterSha256` is new on every file. Nine distinct adapter digests over 208 goldens: each golden now pins only the module that mounts it. No observation moved. The whole-diff census against origin/main reports exactly four changed keys and nothing else: 208 "adapterSha256": 416 "baseline": 416 "goldenFormatVersion": 416 "recorderSha256": Recorded in place rather than through the README's detached-baseline dance: this branch changes no product file, so its tree at the merge is byte-identical toc6a7216984under mobile/src, src/shared and the lockfile, and the parity claim stays non-circular. README says so now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the recording drivers to the engine's mutant-seam rule The name scan exempted every `.test.ts` on the ground that a test cannot change a recording. Two of them can: the recording drivers are the recording path. A driver that read the mutant table by path rather than importing it passed both seam checks — the import scan sees no import, and the name scan waved it through as a test: const table = resolve(import.meta.dirname, 'mutants/operation-mutations.ts') console.log(readFileSync(table, 'utf8').length) at the top of `pilot-recordings.test.ts` gave 2 passed before, and after this change fails with ["pilot-recordings.test.ts"]. Only non-driver tests are exempt now. This file lives in `mutants/`, which `recorderSha256` skips, so no golden moves: the recorder suite is green on the existing 208 with zero dirty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the registry parameter no caller varies `pilotMountAdapters` took `registered` so a caller could mount a different module set; all six callers take the default. The header-digest tests vary the registry through `goldenRecording`, which keeps its own parameter and is where the stub roots need it. Engine source, so `recorderSha256` moves and the goldens follow in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens after the registry parameter came out All 208, `recorderSha256` only. The re-record against the previous commit moves 416 lines, every one of them that field: 416 "recorderSha256": Against origin/main the picture is unchanged from the merge: 208 goldens, 0 added or deleted, 0 non-header lines, and exactly four keys differing — 208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256" Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): wrap the recording README at the width the rest of it uses Seven lines this branch added ran past 100 columns, worst 124. No wording changed. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the worktree overlay, not the archive that cannot work `git archive` was offered alongside a detached checkout as a way to lay this branch's recorder over the pinned baseline. It cannot work: the fence in scripts/rpc-recording.mts runs `git diff --quiet <baseline>` and an untracked-file check, both of which need a real `.git`. In an archive tree git exits non-zero for lack of a repository and the script reports "Product sources or lockfile differ from the pinned main baseline", which reads as a product mismatch that is not there. The transport agent lost time to exactly that. Names `git worktree add --detach` only, and says what the misleading failure looks like if someone tries an archive anyway. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close two ways an adapter module escapes its own digest Two holes, one class: the seam was checked by how an import was spelled and by what the register's values evaluated to, never by where they resolve or where they were written. Inward imports: the scan dropped every specifier starting with `..`, so `'../adapters/settings-mount-adapters'` climbed out of the directory and back into it unseen. A reviewer had `new-tab-agent-mount-adapters.ts` project a value read from the settings module, edited that module, and watched the mounted state change while the new-tab adapter digest held. Specifiers now resolve against the directory and anything landing back inside it fails: ["new-tab-agent-mount-adapters.ts imports ../adapters/settings-mount-adapters"] The register: `adapters/mounted-operation-modules.ts` is pinned by nothing — `recorderSha256` skips the directory and `adapterSha256` reads each entry's `source`. An `exposes` written inline there drives the mounted product module with no digest covering it. The same reviewer replaced the new-tab entry's `exposes` with a literal overriding `loadMobileNewTabAgentOptions`; twelve fence tests passed. Both `mounts` and `exposes` must now be identifiers the register imports from that entry's own module: ["new-tab-agent-mount-adapters.ts writes exposes inline instead of importing it"] Checked on the register's syntax, not its values, because an inline literal and an imported binding are indistinguishable once evaluated. Pinning the register in the engine digest would also close it, and is the wrong trade: every domain adding a register line would re-digest all 208 goldens, which is the conflict this PR exists to remove. Keeping the register an index costs nothing and keeps a domain's line local. Both fixes live in a `.test.ts` outside the drivers, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the mutant seam from the drivers out, not by spelling The seam rested on a grep for the literal `mutants`, which the exported `MUTANT_DIRECTORY` spells without containing. A reviewer had `pilot-mount-adapters.ts` read the mutant table through that constant and both checks passed. The README's claim — that nothing on the recording path names the directory — was false as written. Three changes, in order of strength: Reachability is now proved forward. The suite walks the static import graph from the two recording drivers and fails if any module under `mutants/` is in it. That answers the real question, what a golden's bytes can depend on, instead of the old inward scan's question, who mentions this directory. Non-emptiness is asserted on both sides so a graph that resolved nothing cannot pass by reaching nothing. The name scan covers both spellings, for paths a module can be read by rather than imported. The reviewer's probe now fails as ["pilot-mount-adapters.ts"]. `MUTANT_DIRECTORY` is no longer exported. Its two consumers were both tests of the digest, and they now spell the path instead, which is strictly better for them: a test that imports the constant follows a rename silently, while one that spells it fails on a rename — and that specific directory name is the whole soundness argument. This edits `recorder-digest.ts`, so the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens after MUTANT_DIRECTORY stopped being exported All 208, `recorderSha256` only. Against the previous commit the diff is 416 lines and every one of them is that field: 416 "recorderSha256": Against origin/main, unchanged: 208 goldens, 0 added or deleted, 0 non-header lines, four keys differing — 208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256" Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the mutant seam's actual argument, and its edge The README claimed nothing on the recording path names `mutants/`. That was the old inward scan's claim and a reviewer falsified it with the exported constant. It now describes what the check does: a forward walk of the import graph from the two recording drivers, plus a name scan in both spellings for read-by-path, plus the constant no longer being exported. It also names the case neither closes — a path assembled from fragments at runtime. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the engine/adapter seam in both directions The inward scan only held adapters to the seam. An engine file importing an adapter executes code its own digest skips and that every golden recorded through another domain leaves out of `adapterSha256`, so the register is now the only crossing allowed from the engine side. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name what the driver walk missed instead of counting it Seeding `seen` with the drivers made the driver-presence check true by construction, and the size bound compared a graph inflated by `typeof import` product modules against a recorder-sized number. Both go; the walk now reports the recording files it failed to reach, which is empty today and names an orphan engine file the moment one appears. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): reflow four paragraphs left ragged by the rewrap Orphan fragments only, no wording change: the golden-schema field list, the mutant-evidence paragraph, the probe-witness sentence and the re-anchor note. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the dictation, terminal, notification and browser domains against main Adds scenarios and mount adapters for six small feature areas before any product file moves, so the migration that follows has a frozen main oracle to be measured against: dictation setup and session, agent history, terminal input and viewport refit, push registration and the hosted browser's pointer, keyboard and dialog commands. Five new adapter modules, one per domain, each pinned by its own `adapterSha256`. Two declare exposures: `sendRegister`/`sendUnregister` are module-private in push-registration and their exported callers read the keychain host catalog first, and the history hook reaches its client through the shared per-host context rather than a parameter, so the provider is the mounting boundary. `native-mounting-substitutes.ts` is copied verbatim from the transport migration (#20667) so the two branches merge, extended with the members these hooks read: `AppState` and `useWindowDimensions` on react-native, the two-way audio module, `expo-keep-awake`, and `buffer`. Every device event source is inert — no listener is fired — because each send is driven through the operation's own API instead. Wiring the substitute table into the loader moves `recorderSha256`, so every golden's header re-digests. The product tree is unchanged, so `baseline` is unchanged and the 208 existing goldens move exactly one line each; no observation moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the dictation, terminal, notification and browser domains through typed RpcOperations Replaces 42 raw `sendRequest` reaches across six feature areas with declared operations: dictation setup and session, agent history, terminal input and viewport refit, push registration and the hosted browser's page commands. No wire change — every golden recorded in the previous commit still compares byte-identical, which is the evidence. Acceptance is preserved site by site rather than unified. Every dictation and browser refusal already raised the host's message with a screen fallback, so those share one policy and keep their own copy at the call site. The terminal's two input sites read one boolean off an object result, which `object-result-or-null` gives them without a throw. The worker-takeover report and the three enrichment reads in the resume sheet are skips. Two latent behaviours are preserved deliberately rather than tidied. `repo.list` reads `.repos` at the return statement, so a null result throws a raw TypeError there and not a wrapped refusal message; the three enrichment reads beside it are optional-chained and tolerate the same null. `speech.dictation.finish` checks its refusal before the staleness guard and reads `.text` after it, so the member read stays at the call site. `RpcSendArguments` now admits an explicit `null` where the catalog declares no params. Four shipped senders put `params: null` on the wire for such a method, and a frame carrying a null is not the frame that omits the key; without this the migration would have rewritten those bytes. `src/settings/native-voice-settings-operations.ts` widens its client type for the same reason — it holds the port only to hand it to dictation. Three references are left behind, each listed in the inventory with what blocks it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): matrix the dialog-dismiss site and name the three reaches left behind The reply matrix drives every reply a family's base scenario scripts, and the browser dialog family scripted only the accept leg, so repointing `browser.dialogDismiss` at another method survived the whole suite. A scenario for the dismiss leg closes it; the mutation is killed now. The inventory loses five section headers that no longer list anything, and the three entries that resisted migration each carry what blocks them: a `worktree.ps` inside the history screen component's own effect, an unsubscribe closed over inside a `subscribe` callback, and a `notifications.getMissedSince` gated behind the OS notification tray and the keychain host catalog. Faking either of the last two would record device state, not a wire. Eighteen goldens move on `adapterSha256` alone: removing two inner casts from the browser and history adapters re-digests the goldens mounted through them and nothing else. All 263 were re-recorded from the pinned baseline and compare byte-identical to the previous recording apart from those headers and the new file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): fold five refusal-to-message spellings into one helper The same three lines appeared five times in this PR: interpret a reply, and turn a refusal into a plain Error carrying the host's message or the screen's copy. `interpretOrThrowRefusalMessage` lives beside `refusedRpcMessageOrFallback` and takes the interpretation as a thunk, so the caller still awaits the request outside the catch and a transport rejection reaches it as the object the transport threw, delivery-unknown mark intact. That removes the browser hook's own `assertBrowserCommandAccepted`, whose first parameter named an operation the helper then used only to call `interpret`. Every browser page command is built by one factory with one acceptance, so substituting one operation's `interpret` for another's was unobservable and the parameter read as load-bearing when it was not. The call sites now name the operation where they request and where they interpret, the shape the dictation sites already used. Main's eight source-control spellings are deliberately untouched; folding those in is its own PR. No wire change and no golden moves: `git diff --name-status origin/recorder-adapter-digest...HEAD -- mobile/rpc-foundation` is byte-identical before and after at 55 added and 209 modified. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the history screen holding for a late worktree list The hook holds loading when a scoped tab has no active worktree and the worktree list has not arrived, rather than firing an unscoped scan that would briefly show unrelated host history. The adapter hardcoded `worktreesLoaded` to true and its `worktrees-loaded` action was dead, so no golden reached that branch and the hold was unrecorded. The list and the flag now move together, which is how the screen learns them, and `mount` takes `worktreesLoaded: false` to start unloaded. The new scenario records the hold, then the late load. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens at the pinned baseline Recorded from a detached worktree atc6a7216984with this branch's rpc-recording/ and pilot-scenarios.json overlaid, per the README's migration-branch procedure. Only header fields moved; no body line in any golden changed. recorderSha256 moved on all 208 pre-existing goldens: this branch adds native-mounting-substitutes.ts at the top of rpc-recording/ and routes operation-module-loader.ts through it, and both are recorder-engine inputs. That resolves itself when #20667 lands the same substitution on main. adapterSha256 moved on exactly four goldens, all from the late-worktree-list scenario's edit to adapters/agent-history-mount-adapters.ts: aivault-history-scan-fulfilled, aivault-history-scan-unsupported, matrix-aivault.history-aivault.listsessions-1, matrix-aivault.history-status.get-1. aivault-history-scan-worktrees-late.json is new. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): ask these call sites for a request port, not a whole client The migration widened seven files from `Pick<RpcClient, 'sendRequest'>` to the full `RpcClient` for no reason: a bound operation's `request` takes `UnvalidatedRpcRequestPort`, which is structurally that same single member. Name the port instead, so the signature says what each function actually needs and a caller holding only a port still satisfies it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make these adapters reject an action no scenario dispatches Each of the five new mount adapters ended its `action` with an unnamed default, so a misspelled or engine-introduced name silently ran the last branch: probing `remount` on the history adapter ran `onRefresh()` and put `status.get` on the wire. Twelve pre-existing adapters throw `Unknown X action` instead; these now do too. Deleted with it: every branch no scenario can reach. `unmount` is dispatched only by the lifecycle derivation in derived-goldens.ts, which is restricted to LIFECYCLE_BASES, and none of these scenarios is in it; teardown goes through `dispose`, which already unmounts. Same for the history adapter's `retry`, `select-scope` and refresh, the dictation start flow's `disable`, and the viewport adapter's `reconnect`. None of them handled `remount`, which the schedule driver always pushes after `unmount`, so the pairing was never whole. The three single-action entries keep `_name`, matching six pre-existing entries that do the same. Goldens move on adapterSha256 only and are re-recorded separately. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop four fallback strings no catch can read Both pointer paths wrap their whole sequence in `catch {}` with an empty body, so the message the refusal helper builds is discarded. `interpret` already throws on a refusal under `require-result-or-throw-message`, and the helper only rewrote the text, so removing it keeps the same control flow: the sequence still stops at the refused leg, the later commands still go unsent, and `setError` still does not run. The mousemove matrix golden records `error: null` under every refusal and transport shape either way. The helper stays where the throw reaches a caller: dictation and agent history. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read the viewport outcome where the reader is `readTerminalUpdateViewportOutcome` had one caller, the reader that wraps it, so the name bought a second file to open and nothing else. Inline the two comparisons and keep the outcome type where it was. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record this branch's goldens after the adapter cleanup Recorded from a detached worktree atc6a7216984, this branch's rpc-recording/ and pilot-scenarios.json overlaid, per the README's migration-branch procedure. 266 recordings, all reproduced. adapterSha256 moved on all 56 goldens this branch owns, because the named throws and the deleted unreachable branches changed all five adapter modules. No other header field moved, and no body line in any golden changed: the wire, the effects and the state snapshots are identical, which is the claim the five review deletions rest on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): reach the narrow port type without naming the port module The previous commit named `UnvalidatedRpcRequestPort` by importing the port module, and that fails the boundary ratchet: it counts an import of `unvalidated-rpc-request-port` as reach, so all six unlisted files became offenders and the history panel went from 1 reference to 2. Main's `Pick<RpcClient, 'sendRequest'>` fails it for the same reason, by a different rule: a bare `'sendRequest'` string literal is counted too. That is why the migration widened these signatures in the first place, so the review finding's premise that it was done for no cause is wrong. Only the full `RpcClient` scored zero. Re-export the port type from `rpc-client` instead. An export declaration with no module specifier is not counted, the seven signatures still say they need one sender rather than a whole client, and the inventory does not move. Holding a client already carries the same reach, so nothing new is opened. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * Revert "refactor(mobile): reach the narrow port type without naming the port module" This reverts92e679f587and16c9c232fcas one commit, returning the seven client signatures to the full RpcClient the migration gave them. unvalidated-rpc-request-port-boundary.test.ts:192 pins an import of the port type as one reach by design: naming the type is exactly what the ratchet retires, so re-exporting it from rpc-client opened an uncounted path for every future file. Widening to the client is the design's intended end state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): restore this branch's native substitutes and re-record at the pin Taking #20667's landed `native-mounting-substitutes.ts` verbatim dropped the substitutes this branch's domains need, and five scenarios stopped recording: `speech-audio-chunk-acknowledged`, `speech-dictation-session-transcript`, `speech-dictation-session-cancelled`, `terminal-viewport-refit-applied` and `terminal-viewport-refit-legacy-desktop` all failed with `Missing or completed request`, because the hook throws on the native member before it sends. Both branches created that file independently; neither is a version of the other. Main's structure is kept whole — `partialNativeModule`, the `__esModule` rule, the async-storage trap — and this branch's boundaries are added inside it: `buffer`, `AppState` and `useWindowDimensions` on `react-native`, `@orca/expo-two-way-audio`, `expo-keep-awake`, and `expo-secure-store` as a second unusable store. Each is inert; no listener is ever fired and no audio is produced. That is an engine edit, so every golden re-digests. Recorded at main's pinc6a7216984from a detached worktree with this recorder overlaid, and the product tree there was byte-identical to the pin. All 397 goldens moved on `recorderSha256` alone and nothing else: git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion|baseline|scenarioSha256|lockfileSha256)":' | wc -l 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record at the pin after the #20685 merge The merged recorder is a third engine: main's post-tasks-2 files plus this branch's native-mounting-substitutes.ts, so recorderSha256 moves once and every golden re-digests. Recorded from the pinned baselinec6a7216984in a detached worktree with this tree's recorder laid over it, so the product source is still main's pre-migration tree. All 509 goldens move on recorderSha256 alone; no recorded wire byte changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(lint): avoid reflective status entry reads (#20872) * fix(relay): resolve packaged node-pty from resources * fix(lint): avoid reflective status entry reads * refactor(agent-launch): make the launch-mode decision surface-neutral (#19848) * 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. * 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. * 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. --------- Co-authored-by: Merge Sim <sim@local> * fix(crash-reporting): stop periodic emitters from evicting the crash trail (#20639) * fix(crash-reporting): stop a once-a-minute sampler from evicting the crash trail The breadcrumb ring is 30 entries and evicts oldest-first, so any emitter that repeats outlasts the whole lifecycle trail. Across 293 field reports three periodic emitters hold 77% of every slot ever shipped and 39% of reports arrive with no lifecycle crumb at all — the "Recent activity" section cannot say what the app was doing. Charge the overflow to the most crowded name instead of the oldest event, so a series is thinned from its oldest end and singletons survive. No allowlist, so a new periodic emitter cannot reopen the hole. * test(crash-reporting): pin coalesced-burst accounting under mid-ring eviction * fix(crash-reporting): scope eviction per origin and spare live coalescing owners Round-1 review found two ways the name-only policy was worse than plain FIFO: - Counting ignored `origin` while the snapshot filters by it, so a busy popout's samples made the main window's singleton look redundant and deleted it. - Names like `renderer_error` carry many independent coalesce keys, so the name became "crowded" out of genuinely distinct errors — and the entry taken was the oldest, i.e. a key still accumulating `suppressedSinceLast`. A crash report is the last snapshot, so an orphaned owner is never re-claimed and the burst count simply vanished. Group by (name, origin), skip an entry a coalesce key still owns unless every candidate is owned, and never consider the crumb that just arrived — its coalesce state is linked after the push, so it would always look unowned. * fix(crash-reporting): trim the report window by the same policy as eviction Round 2 found the fix defeating itself. Fair-share eviction parks one-off crumbs at the ring's HEAD and the repeating series at its tail — and the snapshot then took a plain tail slice of `MAX_BREADCRUMBS - retained.length`, trimming exactly what eviction had just protected. Measured on the previous commit: one retained `renderer_memory_highwater` cost one lifecycle crumb, and three erased the lifecycle trail from the report entirely. That lane fills under the same memory pressure that produces the `renderer_memory` flood, so the two cancelled out precisely when the trail matters most. Trim with `evictionIndex` instead, and route `isCoalescedCrumbStillInEvidence` through the same window — a predicate that disagrees with the snapshot would drop an owner's handle and lose the burst count from the crumb the reader sees. Also strengthens the uncoalesced-burst test, whose only remaining delta against its coalesced twin was the slot count: it now asserts the pane population is absent on the uncoalesced side, which is the signal coalescing exists to keep. --------- Co-authored-by: m4air <m4air@Mac.localdomain> * fix(native-chat): replace the raw question tool row with an awaiting-input row (#20724) * fix(native-chat): replace the raw question tool row with an awaiting-input row A question tool call rendered as ordinary tool activity — "Running AskUserQuestion" with a clipped JSON payload while live, then a "1x AskUserQuestion {...}" run header once settled — so the one row the reader actually has to act on read as machine output. It now draws as "Awaiting user input: <question>", led by a comment-bubble glyph, with the label pulsing while the answer is outstanding and reading "Asked: <question>" once it lands. A grouped prompt names how many questions it asks rather than quoting only the first, since one row stands for the whole prompt. Question calls also leave the run header, so the count beside them reports only the work that actually ran. Codex journals only the question and never a call for it, and a pending question was dropped from the transcript entirely — its chat log said nothing while the agent sat blocked on the reader. Pending questions now project the same row. Claude journals both the call and the question it raised, so the call itself is suppressed and the one row is fed from one source. * refactor(native-chat): derive the awaiting-input row from the question item The first pass fabricated a synthetic `request_user_input` tool call inside the shared journal projection so that one renderer could serve every lane. That made a presentation choice on behalf of every consumer of that projection, including archives and older RPC clients that never asked for it. Question presentation is now client-local. The shared projection is restored untouched, and the desktop transcript derives its own rows: a pending question keeps a stable identity row through tool folding while its receipt draws the awaiting line, and the duplicate AskUserQuestion call Claude journals beside the question it raised is suppressed only when a matching question is open in the same turn — so an unmatched call, or one from a lane that journals no question, still reports itself. Question calls now leave the run together with their paired result, which stops a summarized ask from stranding its answer as an orphan Result row. A failed ask keeps its error instead of being folded into the awaiting row, and an ask no longer contends with a concurrently running tool for the active slot: both are reported. Adjacent pending questions — the shape Codex journals, one item per question — group into a single awaiting row that narrows as each one is answered. Also ships the three awaiting-row strings in the runtime-required English catalog. Their call-site fallbacks are a shared constant rather than string literals, so i18next cannot rebuild them from the call site and they have to be present for the static-analysis gate to pass. * fix(native-chat): preserve unmatched duplicate question calls * fix(native-chat): avoid repeated grouped question text * fix(native-chat): keep pending question text specific * fix(native-chat): avoid repeating single question answers * fix(native-chat): narrow question receipt subject * fix(native-chat): preserve settled ask calls * fix(native-chat): cover bridge ask rows * fix(native-chat): fold settled ask receipts * test(native-chat): cover settled ask receipt folding * fix(lint): replace Reflect.get with typed property access in mounting substitutes (#20874) * fix(lint): replace Reflect.get with typed property access in native mounting substitutes main's tip fails `pnpm run audit:anti-slop` (the `static analysis` CI gate) on `no-reflect-get` in mobile/src/test-support/rpc-recording/native-mounting-substitutes.ts, blocking every open PR. The Proxy get trap's key is `string | symbol`; branch on that to keep typed bracket access for strings and a symbol-indexed cast for symbols, preserving the existing throw-on-unsubstituted-member behavior exactly. * test(rpc-recording): re-record goldens for the recorderSha256 shift native-mounting-substitutes.ts changed bytes, so recorderSha256 (which pins every non-adapter file under this directory into every golden's header) moved. Re-recorded all 509 goldens; only recorderSha256 differs in any of them, confirming the checkpoint content is unchanged. * test(native-chat): widen real-timer waitFor budget in agent-session-wire handoff tests (#20880) vi.waitFor defaults to a 1000ms/50ms real-clock budget on this suite (no useFakeTimers), which is occasionally too tight for host.requestHandoff / handoffStatus to settle under a loaded CI shard. Production behaviour is unchanged; the assertions are correct, just sometimes slow to observe. vi.waitFor's own poll loop always runs on the real clock (vitest resolves its interval/timeout via getSafeTimers, which bypasses vi's faked globals), so the lease-renewer test carries the same real-wall-clock exposure despite calling vi.useFakeTimers() for the simulated renewal interval. 5000ms follows existing repo precedent for explicit vi.waitFor timeouts on real-timer waits (e.g. ssh-relay-session-rejected-delivery.test.ts, daemon/client.test.ts, pty-subprocess-io-failure-native.test.ts, windows-msys-job.win32.test.ts), which range 1500-15000ms. * fix(mobile): retire a structured operation id the host has refused (#20868) `agentSession.cancel` kept its client operation id whenever the outcome came back unknown. One of those unknowns is not transport doubt: when the host answers `agent_session_operation_unknown` it has decided about that id and will not run it again, because cancel's mutation plan recovers no unknown ledger row. Every later Stop on that turn re-sent the same refused id, so Stop stayed unusable until the row expired. The RPC layer collapsed both cases into a bare `unknown`, discarding the difference between "the effect is in doubt" and "the host answered about this id". It now reports the second case, and cancel spends the id there while still replaying under genuine transport doubt. `agentSession.conversationCommand` deliberately keeps its id: its plan sets `recoverUnknownFromDurableState`, so a reused id can still replay or rerun. * fix(lint): merge duplicate type imports in the mobile RPC recorder adapters (#20895) * fix(lint): merge duplicate type imports in the mobile RPC recorder adapters The native code-quality audit rejects a module imported twice in one file, so main's static-analysis job is red for every open PR. * test(mobile): re-record RPC goldens against the merged adapters The duplicate-import fix changed two mount adapters, so the nine goldens that pin them by adapterSha256 needed re-recording. The recorder fence requires the pinned baseline to match the product tree, so the baseline moves to current main, which rewrites that header in all 509 goldens. Every recording body is identical, which also shows the commits between the two baselines changed no observed behavior. * fix(native-chat): release sessions after provider root exit (#20502) * fix(native-chat): bound structured chat launch * Fix post-merge test hygiene * Make structured fallback settlement exhaustive * fix(native-chat): release sessions after root exit * chore(i18n): remove legacy fallback copy * test(native-chat): remove terminal fallback census * docs(native-chat): clarify root-exit lease proof * chore(native-chat): drop unrelated formatting * fix native chat launch visibility * test(native-chat): split message rail windowing coverage * fix(native-chat): keep transport gating render-pure * fix(native-chat): coordinate launch prompt settlement * test(native-chat): align unified close ownership * fix(native-chat): correct lifecycle imports and test typing * fix(native-chat): fence restored launch cancellations * fix(native-chat): fence authoritative cancellation snapshots * test(mobile): record the real worktree catalog snapshot result in the RPC recorder (#20873) * test(rpc-recording): record the real worktree catalog snapshot result `worktree.catalog-snapshot`'s action returned `WorktreeCatalogSnapshotClient.fetch`'s raw result. That value nests the live `RpcClient` under `pending.client`, so `captureValue` threw `Unsupported observation: function`: both goldens baked an `unhandled-rejection` effect and left the action's settlement `pending`, proving nothing about what the fetch returns. Project the result through `projectObservable` on the settlement path, the same way `state()` already shows it and the same way #20667 fixed `transport.pairing-race`. Rejections still propagate unchanged, so the two transport-rejection matrix partitions keep their recorded errors. The matrix golden now discriminates all eleven reply partitions: a full admission, six invalid ones, three `request_failed` codes and two rejections. `recorderSha256` does not move; `adapterSha256` moves on the six goldens mounted through this adapter module, four of which have no other change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(rpc-recording): fail the suite when a golden records a recorder failure A projection refusal settles as data, not as a failed run: `captureValue` throws inside the action, the recorder captures it as an `unhandled-rejection` effect, and `--record` writes a green golden whose action never settled. The class has landed twice — `transport.pairing-race` in #20667 and `worktree.catalog-snapshot` in the previous commit — and reverting either one plus a re-record would go green with the broken golden back. Read every golden and fail on an `unhandled-rejection` effect or on either of `captureValue`'s refusal texts in any observation field. A positive control in the same case asserts both detectors fire, so the absence claim is load-bearing rather than vacuous. Not a recording driver, so `recorderSha256` excludes it and no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(rpc-recording): ban only the recorder's own refusals, not product bugs The gate also failed on any effect named `unhandled-rejection`, which bans a real observation: detached-rejection capture exists to pin a main bug in a golden, and `unhandled-recording.test.ts` pins the capture precisely because no golden carries one today. Banning the name would make recording a genuine product failure a test failure. Drop that detector. `refusalText` alone catches all seven checkpoints of the worktree-catalog regression, because the refusal is the message of the captured error rather than the effect's name. The positive control now seeds that shape — the refusal inside a recorded error under `effects` — so the surviving detector is still proven to fire. Vacuity guards on golden and checkpoint counts are unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(rpc-recording): re-record the worktree catalog goldens over main's engine digest #20874 re-digested `recorderSha256` on all 509 goldens, so the six goldens mounted through the worktree catalog adapter had to be recorded again from the pin rather than merged. Bodies and `adapterSha256` are unchanged from the pre-merge recording. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mount screens, declared device stores and declared OS state in the RPC recorder (#20884) * test(mobile): mount screens, declared device stores and declared OS state in the RPC recorder Three recorder capabilities, each with a test, plus four holdout sites recorded against main's product code to prove them. No product source changes. - JSX compiles through the automatic runtime, which is what product sources use; the classic `React.createElement` emit threw `React is not defined` on the first render of every screen. - An unlisted package answers `__esModule` as undefined, so a default import loads and the refusal defers to the first real member read instead of killing the module at load. - The substitutes table gains the inert view packages a screen needs, split into `screen-native-substitutes.ts` behind the same rule: only what a screen reads is listed. - A scenario may declare `deviceStore` and `deviceState.notificationTray`. Reads resolve the declaration or null and never a write; writes are recorded as effects. Undeclared is unchanged. - `screenMount` mounts a component with a crash boundary, so a reply partition that takes a screen down is a recording rather than a suite failure. Every pre-existing golden re-records byte-identical except `recorderSha256`, recorded from a detached worktree at the pinned baseline. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): declare the crash boundary's state type instead of asserting it The changed-code casting gate counts `null as string | null` as a type assertion. Re-records every golden from the pinned baseline, because the edit moves `recorderSha256`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refuse an unlisted native package by the member a mount reads Both emitted interop helpers short-circuit on `__esModule`, so answering `true` hands the refusing trap back unwrapped to `__importDefault` and `__importStar`. All three import forms now load the importer and throw the named refusal at the first member read, instead of a namespace import silently yielding `undefined` and failing later at the call. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): run a task deferred past interactions instead of dropping it An inert `runAfterInteractions` swallows whatever send the screen deferred, and the recording then claims the screen sends nothing. It runs the task on a microtask and returns the RN-shaped handle, so a cancel before the task runs still prevents it. `Alert.alert` stays inert. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden for the two recorder engine changes Only `recorderSha256` moves, from c58067de to a59b30e7. Recorded from a detached worktree at the pinned baselinec6a7216984with this branch's recorder and scenario file overlaid, so no golden body is a claim about migrated source. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): stop exporting three recorder symbols with no importer `projectScreenTree`, `DeviceEffect` and `DeclaredNotification` are only used by the file that declares them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden for the dropped exports Only `recorderSha256` moves, from a59b30e7 to 4df43aef. Recorded from a detached worktree at the pinned baselinec6a7216984with this branch's recorder and scenario file overlaid. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): list only the native members a recording is known to read Instrumenting `partialNativeModule` across all 522 recordings found 68 declared members and 23 read. The unread ones are gone: the whole react-native-svg, react-native-reanimated and react-native-gesture-handler entries, two expo-router hooks, and twelve react-native members including `InteractionManager` and `Alert`. A member listed before a recording reads it turns a refusal that would have forced a decision into a silent stand-in, which is how an inert scheduler swallows a deferred send. Wave 3 re-adds each one with the recording that reads it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): order each effect against the sends with a send count Sender and effects are two independent lists, so a send reordered ahead of a device write moved neither. Scheduling the codex journal write on a timer instead of awaiting it left all 520 goldens byte-identical; with `sent` it moves two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): report a boundary crash through the effect sink A hook mount projects the hook's own value and never a crash, so an adapter that never reads `crash()` recorded a screen that quietly stopped rendering. The boundary now reports to the effect sink as well, which forces a cleanup checkpoint and puts the crash in the golden with no adapter cooperation. Two reply-matrix goldens gain the effect. Also folds the two near-identical tree walkers into one `walk(tree, visit)`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): state the __esModule interop rule once It was restated at four sites with four different answers. The canonical paragraph is in `native-module-traps.ts`; the loader and the declared tray point at it in a line. The loader comment also said the refusal names the member the product read, which for a default import is `.default` instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record for the send ordinal, the crash effect and the pruned table `recorderSha256` moves on all 520. Of the 509 pre-existing goldens, 260 are header-only and 249 gain `sent` on their effects and nothing else; two of this branch's own reply-matrix goldens also gain a `screen.crash` effect. Recorded from a detached worktree at the pinned baselinec6a7216984. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(lint): drop Reflect.get from the native traps main adopted anti-slop's `no-reflect-get` in #20874 and fixed the copy that lived in `native-mounting-substitutes.ts`. This branch moved both traps into `native-module-traps.ts`, so the rule lands here instead and the static-analysis gate is red without the same narrowing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record for the Reflect.get narrowing `recorderSha256` only, 8ca81bc6 to 0ddc0dc4. No golden body moves, which is the claim the narrowing makes: the trap reads the same member it always did. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record every golden at main's new baseline pin One record from a detached worktree at `e7206f62a8` — main's pin since #20895 — with this branch's `rpc-recording/`, `pilot-scenarios.json` and `scripts/rpc-recording.mts` laid over it, so the goldens are recorded against main's product tree rather than a branch that edits the engine. All 520 goldens now share one `recorderSha256` (`0ddc0dc472e2efbe58004c6dfaf5360e368fed321e40dfe15c11a9ad1817dea4`) and one `baseline`. Against main, 262 move on the header alone and 247 also gain the `sent` ordinal on an effect, which is the whole of the body change; the nine goldens #20895 re-digested carry a byte-identical body, and the six the new worktree catalog adapter (#20873) recorded keep main's observation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(cli,relay): stop reading an unsignalable pid as a dead one (+ unverifiable-collapse sweep result) (#20098) * fix(cli): stop reporting an unsignalable Orca pid as a stale bootstrap `orca status` falls back to a `kill(pid, 0)` probe when `status.get` cannot be reached, and a bare catch read every refusal as absence. EPERM means the pid exists under another uid -- an Orca reached via ORCA_USER_DATA_PATH, or one started with sudo -- so a live app was reported `running: false`, `pid: null`, `runtime.state: stale_bootstrap`, `graph.state: not_running`. Only ESRCH proves the pid is gone, which is the rule every other liveness probe in the repo already applies (`isProcessAlive` in relay/pty-shell-utils.ts, pack-refs-lock-ownership.ts, runtime-metadata-ownership-watch.ts, and agent-session-process-identity-probe.ts). See docs/reference/ssh-execution-boundary.md. * fix(relay): keep a revived pane whose pid only refuses the liveness probe `revive` gated each serialized pane on a hand-rolled `process.kill(pid, 0)` in a bare try/catch, so any refusal retired the pane. EPERM means the process exists under another uid; only ESRCH is evidence of absence. The file already imports `isProcessAlive`, whose ESRCH-only contract `reapPtyProvenExited` documents 450 lines earlier -- this call site just did not use it. Reuse it rather than keeping a second implementation of the same concept. Malformed pids still skip, as before. See docs/reference/ssh-execution-boundary.md. * fix(lint): clear the casting gate on the pid-probe changes main tightened typescript/consistent-type-assertions to assertionStyle: never, which the rebase brings onto these added lines. The CLI probe narrows instead of casting; the relay test keeps the file's serialize idiom behind a SAFETY-annotated suppression. * fix(ssh): corroborate an empty lsof answer before calling an endpoint free (#20585) #18304 decided enumerability after `lsof` runs, keying on stderr, non-numeric output, and an abnormal exit. One failure carries none of those signals: probing as a uid that does not own the socket's holder, `lsof -t -a -U <path>` exits 1 with no stdout and no stderr. Measured on Debian 12 against #18304's own probe, a live relay owned by root probed as `nobody`: probe uid path marker pids merged nobody held.sock lsof [] <- live relay holds it merged nobody stale.sock lsof [] <- genuinely nobody merged root held.sock lsof [10] merged root stale.sock lsof [] The first two rows are byte-identical, so nothing about lsof's answer can separate them. The first reaches `verdict: exited / evidence: no-holder`, which `classifySupersededRelay` maps to `stale-endpoint-removed` and `rm -f` on an inode a live relay is still holding. `hidepid=2` produces the same shape. A positive control does not solve this. Controlling on something the probe itself holds passes precisely when we are blind: as `nobody`, `lsof -t -p $$` returns a pid while the socket query returns nothing. Blindness is to *other* uids, and another uid's process is not ours to manufacture. /proc/net/unix is. It is world-readable and lists every bound unix socket regardless of owner, so an entry for the path alongside no reported pid proves lsof was blind rather than that the path is free. Only an otherwise-clean empty answer is corroborated; a reported pid still stands on its own, and the check is skipped when the answer was already unavailable. Same run, with this change: fixed nobody held.sock unavailable [] <- no longer reapable fixed nobody stale.sock lsof [] <- still reapable fixed root held.sock lsof [10] <- unchanged fixed root stale.sock lsof [] <- unchanged The marker can only ever move from `lsof` toward `unavailable`, so this never authorises an unlink that #18304 refuses. Off Linux there is no /proc/net/unix, the check returns false, and behaviour is exactly as before -- deliberately, because defaulting to `unavailable` there would stop every macOS host from reaping a stale endpoint and trade a rare destructive bug for a universal accumulation one. The tests are Linux-gated for the same reason, with an assertion that the evidence they depend on is actually present so the block cannot pass vacuously. * fix(worktrees): route unstamped local worktrees local in the two states #16841 still fails closed (#16829) * test(worktrees): cover local worktree owner routing with saved runtimes (#16733) A local git worktree whose rows carry no host stamp fails every owner-routed operation closed as soon as any runtime environment is saved, however unrelated. resolveWorktreeOperationRouteResult establishes positive identity from the worktree/repo catalogs, then discards it: with no runtime active the only exit is the legacy-local gate, which demands an empty saved-runtime list. One saved environment makes that false and the call returns { kind: 'missing' }. These tests state the contract before the fix, so the claim that the fix is purely additive can be checked rather than asserted. Committed red on purpose. Observed at5631aa00dd(vitest run, both files): Tests 7 failed | 52 passed (59) The 7 failing are exactly the states that must become local, plus their two consumers: - an unrelated runtime is saved (the reported bug) - several unrelated runtimes are saved - the repo is known before its worktree row is listed - the saved-runtime catalog has not hydrated - an unrelated runtime was removed - resolveTerminalWorktreeRoute on such a worktree (the gate in front of the "Terminal creation is unavailable" reply) - the folder/worktree parity state: identical store, folder local, worktree missing The other 52 pass now and must keep passing: connection-owned and runtime-stamped repos never route local, a stamped worktree row still outranks its repo, contradictory repo rows stay ambiguous, an ambiguous or hydrating runtime focus still fails closed, and a genuinely unknown id still fails closed. That set is the additive-only guarantee. * test(worktrees): re-aim two fail-closed cases at genuinely missing owners (#16733) Two cases in src/renderer/src/lib/worktree-operation-route.test.ts assert the behaviour #16733 reports as the bug, so they have to move: - :158 'fails a paired-client ownerless stale publication closed instead of routing it locally' - :171 'fails ownerless rows closed until the saved-runtime catalog is hydrated' Both arrived with #9994 (41751dd90d, route HUB-owned SSH worktrees through their owning runtime), whose stated goal was to fail closed for missing or stale owners. That goal is right and is kept. The premise being rebutted is narrower: neither fixture describes a missing or stale owner. Each carries a present repo row that is merely unstamped -- repos: [{ id: 'repo-1' }] -- and three places in this codebase already read exactly that row as locally owned: - shared/execution-host.ts getRepoExecutionHostId returns LOCAL_EXECUTION_HOST_ID - main/ipc/worktrees/listing/worktree-host-ownership.ts resolveRepoOwnershipEvidence falls back to LOCAL_EXECUTION_HOST_ID, and the listing and removal paths trust it - shared/repo-types.ts documents executionHostId as the field runtime-host repos need precisely because they otherwise look identical to local repos attribute. It swept in the legacy-local case because at the time nothing in this resolver consulted the repo index for an unstamped row. So each case is re-aimed at the state it was actually defending, and neither is deleted -- the fail-closed coverage is not reduced, it is pointed at a real missing owner: - the first becomes 'fails a paired-client publication closed when no repo row can own it': same runtime state, repos: []. A worktree row alone is not host evidence, so this still returns missing, before and after the fix. - the second becomes 'fails ownerless rows closed mid-hydration while a saved runtime could own them': an active runtime with an ambiguous saved catalog during hydration. This returns missing from the active-runtime branch and is untouched by the fix. It is worded to stay distinct from the neighbouring case at :185, which already covers focus-is-not-ownership with an empty catalog, rather than duplicating it. The states these two cases vacate are re-asserted with their corrected expected result in the #16733 block added by the previous commit. Suite unchanged at 7 failed | 52 passed (59): the rewrites pass, the 7 reds are still the 7 states the fix must convert. * fix(worktrees): keep unstamped local worktrees routable when runtimes are saved (#16733) resolveWorktreeOperationRouteResult establishes positive identity from the worktree and repo catalogs, then discards it. With no runtime active the only exit is the legacy-local gate, which requires an empty saved-runtime list, so one saved runtime environment -- connected or not, related or not -- made it false and the call returned { kind: 'missing' }. Every owner-routed operation on a genuinely local git worktree then failed closed, and because resolveTerminalWorktreeRoute is the sole gate in front of terminal-request-ipc-bridge.ts, the user saw "Terminal creation is unavailable because the worktree owner could not be resolved". Folder workspaces hit the same gate and were carved out in #10251/#10269, whose comment in this file states the principle and names this exact failure mode: a found record is positive identity evidence, and the worktree legacy hydration gates "would fail local folders closed whenever unrelated runtimes exist". Git worktrees never got the equivalent. This adds it, in the same shape and the same function. The rule is not new. An unstamped repo row is read as locally owned by getRepoExecutionHostId, by main's resolveRepoOwnershipEvidence, and by Repo.executionHostId's own documentation; and the repo write path (repoWithFetchedOwner) stamps runtime: and ssh: owners at fetch time, so an unstamped row is a legacy row that predates owner projection -- local by construction. The router now consults that evidence instead of contradicting it. The sidebar already rendered these worktrees as Local; this removes the disagreement rather than adding a heuristic. Four properties this change holds to: 1. The branch sits after the active-runtime block, so an unambiguous active runtime still wins (routes runtime:<id>, not local) and an ambiguous or mid-hydration focus still returns missing. That ordering is structural, not incidental. 2. mayBeLegacyLocal is left byte-identical (verified: both 7-line hunks hash to f0ed1b4287c646cb). The new branch does take over the two states where a local repo row exists and no runtime is saved, but returns the identical local route, so no input changes its answer -- only which branch produced it. 3. The helper returns null on anything but unanimous local, so the branch can only ever convert missing into local. It never returns ambiguous: a contradiction between repo rows is already decided upstream by resolveExplicitWorktreeOperationRouteResult, and answering it here would be a second, divergent authority. 4. It reads neither runtimeEnvironmentCatalogHydrated nor removedRuntimeEnvironmentIds. That is sound rather than merely convenient, because it consults host evidence rather than runtime-environment inference: a runtime-owned repo row is stamped runtime:<id> at fetch time, so neither an unhydrated runtime catalog nor a removed environment can turn an unstamped row into a remote one. Control only reaches this point after the explicit catalog resolver returned missing, which means every worktree row and every repo row for this id is unstamped -- any stamped row routes ssh: or runtime: earlier, and two disagreeing rows return ambiguous earlier. There is no remote-owned state left here to leak. Out of scope, deliberately: who wins when a runtime is focused (#11512), and back-filling Worktree.hostId at creation time, which is a persistence migration over worktreeMeta and does nothing for the users already carrying unstamped rows. The 7 cases red in the two preceding commits now pass; the 52 that guard the fail-closed contract are unchanged. Test Files 2 passed (2) Tests 59 passed (59) * test(worktrees): defer repo-row-only routing to #16841's fail-closed rule (#16733) Upstream #16841 (mergedd3475957f3) landed its own fix for #16733 and drew the positive-identity line one notch tighter than this branch did: its 'does not treat a repo row alone as positive local identity' case asserts that a worktree id no row has ever listed stays `missing`, even when the repo row for its repoId is local. This branch's 'routes a known local repo before its worktree row has been listed' asserted the opposite result for that identical state, so the two cannot both hold. Main's rule is the safer reading — a repo row is repo identity, not worktree identity — so the reconciled code gates resolveUnstampedLocalWorktreeRoute on hasKnownWorktree and this case is dropped rather than re-pinned. Every state that actually reproduces #16733 keeps a worktree row (listed or detected), so the reported bug and both extra fail-closed edge cases this branch fixes are unaffected. * refactor(worktrees): drop the unreachable disagreement loop in resolveUnstampedLocalWorktreeRoute resolveWorktreeOperationRouteResult only calls resolveUnstampedLocalWorktreeRoute after resolveExplicitWorktreeOperationRouteResult has already returned 'missing' for this repoId. That function (worktree-operation-catalog-route.ts) indexes every repo row carrying a non-empty executionHostId or connectionId and resolves/ambiguous-es on any of them, so by construction every row resolveUnstampedLocalWorktreeRoute ever sees is unstamped -- and getRepoExecutionHostId's own fallback (shared/execution-host.ts) always resolves an unstamped row to local. The per-row disagreement check could never actually return null; it was dead defensive code describing a state the caller's short-circuit already rules out. Reduced to an existence check with identical behavior (verified: same 66/66 tests, same mutation-proof property -- reverting only this file still fails exactly the same 6 tests it did before). Also harmonized a same-function 'local' string literal to the LOCAL_EXECUTION_HOST_ID constant already in use one branch above it, and dropped a dangling getWorktreeExecutionHostId doc reference the shipped code never actually calls. * docs(routing): document the undocumented worktree operation route helpers * fix(worktree): keep a WSL checkout case so delete cannot take the twin branch (#20273) * fix(worktree): let a POSIX path keep its case on a Windows desktop `canonicalWorktreePath` folded case whenever `process.platform` was win32, without asking what the path itself was. A WSL or SSH checkout is spelled `/home/alice/ws/feature` on a Windows desktop too, and ext4 is case-sensitive, so `/home/alice/ws/Feature` and `/home/alice/ws/feature` — two real checkouts on two real branches — collapsed into one row. `removeWorktree` picks the row it is about to remove with that comparison and reads the branch off it. Requesting `/home/alice/ws/feature` removed the right directory (the path rides in argv) and then ran `git branch -d -- Feature`. The same wrong row feeds `assertWorktreeUnlockedForRemoval`, so a locked twin blocks an unlocked delete and an unlocked twin lets a locked one through. Whose filesystem a path names is a property of the path, not of the desktop reading it, so a POSIX-absolute path now takes POSIX rules at any platform and a POSIX/Windows pair is never equal — `win32.resolve` would otherwise give the POSIX path a drive root and manufacture the equality. Windows drive and UNC paths, including WSL UNC aliases, keep folding case as before. Two call sites already carried private copies of this rule (`isSameCommonDirPath`, `ipc/worktree-path-comparison`); this is the same rule at the source. The removal path is the one that never got one. * fix(worktree): keep a WSL checkout's case through the UNC spelling too The first commit gave POSIX-absolute paths POSIX case rules, which is right but does not reach the WSL case it claimed. `listWorktreesStrict` runs every listed path through `translateWorktreePath`, so git-in-the-distro's `/home/alice/ws/Feature` arrives as `\\wsl.localhost\Ubuntu\home\alice\ws\Feature` and the POSIX branch never sees it. The removal suite mocks `translateWslOutputPaths` to identity, which is why the end-to-end test passed without exercising the translation production always applies. Driving the real translator, the original defect survived unchanged: a request naming `...\ws\feature` ran `git worktree remove --force ...\ws\feature` and then `git branch -d -- Feature`. The filesystem behind `\\wsl.localhost` is ext4, so the UNC spelling is case-sensitive for the same reason the Linux spelling is — except where Windows genuinely folds: the `\\wsl$` share alias, the distro name, and a drvfs `/mnt/<letter>` tail, which really is a Windows volume. `foldWslUncPathCaseInsensitiveParts` already draws exactly that line and `git-fetch-head-lock` already depends on it, so this reuses it rather than writing a fourth copy of the rule. Windows drive paths keep folding whole. The end-to-end case now drives the real translator instead of the mock, so the translation cannot go missing again without the test noticing. * fix(worktree-create): prioritize creation Git and defer background preparation (#20722) * fix(worktree-create): run create git commands at interactive tier, defer pool side jobs, bound queue wait by timeout Creating a worktree on a busy machine stalled for minutes because the create's own git competed for the same admission budget as everything else. - The create path never set an admission tier, so it defaulted to 'status' and could never use the scheduler's headroom slots. It now tags the option objects that reach git directly: the add, the post-add listing, the base-ref probes and the prepared-checkout finalize. The speculative warm-up and the SSH path are unchanged. - The prepared-pool re-arm is a full `reset --hard`; it ran mid-create and held a general slot. `consumePreparedWorktreeCreate` now returns it as a thunk the create runs after the startup terminal is spawned. Stale-preparation reclamation (`worktree unlock` / `worktree remove`) drops to 'background'. - A command's timeout only armed once its child spawned, so a saturated queue could hold a 1s command indefinitely. Admission now takes the same deadline and raises GitCommandTimeoutError without spawning; a caller abort still reports as an abort. The tier is kept off the `{ wslDistro }` routing objects: several callers test those for emptiness to decide whether a repo has local git routing at all. * fix(worktree-create): keep a bounded queue wait from reading as an absent base ref The admission deadline added in the previous commit made every create-path probe's 15s/120s budget cover the queue wait. The default-base and worktree-base probes answer `false`/`null` for any failure, so a saturated queue reported a repo that has origin/main as having no default base and the create refused to start. Both probe families now let `GitCommandTimeoutError` through, and the branch-name resolution loop, the push-target configuration and the post-add listing run at the create's interactive tier so they reach the headroom the rest of the create already uses. Also: the deferred pool re-arm re-checks the pool inside the thunk, since `startPreparation` replaces a map entry outright and would strand a prefetch's locked checkout with no owner; the shared worktree scan keys on the tier so an interactive listing cannot inherit a queued status scan's wait; and the deadline's microtask hop is gone, along with two fake-timer `vi.waitFor` calls that jumped the clock past a 10ms budget before the grant settled. * fix(worktree-create): preserve probe fallbacks and defer runtime replenishment * fix(worktree-create): preserve interactive priority through prepared claims * fix(worktree-create): prioritize CLI creation and preserve SHA probe timeouts * fix(worktree-create): scope Git execution policy at creation boundaries * fix(worktree-create): preserve inconclusive Git probe timeouts * test(runtime): align creation fixtures with scoped Git execution * test(native-chat): extract windowing layout fixture to satisfy file limit * fix(git): restore execution-only timeouts while queued * refactor(worktree-create): remove unrelated error-handling changes * chore: narrow review scope and clarify preparation timing * test(native-chat): restore fixture extraction to fix CI lint * refactor(git): keep the admission scheduler in its original module Reverts a move-only extraction. Inlines the single-use command-class wrapper so the tier-resolution import fits the file's line budget. * fix(worktrees): re-arm the prepared pool after CLI create launches terminals The runtime create fired the pool re-arm right after materialization, so its `reset --hard` competed with the startup agent's first git reads. Return the thunk to the caller and fire it last, matching the desktop path. * fix(worktrees): skip a preparation whose checkout is still running An interactive create that claimed an in-flight preparation awaited a checkout queued at background, so on a saturated budget it yielded to every arriving status poller until aging promoted it. The create now misses with not_ready and does its own add at interactive; the preparation stays armed for the next one. Also drops the one-field policy object from the Git operation executor. * fix(worktrees): report repo_mismatch before not_ready when selecting a preparation The readiness filter ran before the same-repo check, so another repo's in-flight preparation was labeled not_ready instead of repo_mismatch, hiding the cap-thrash signal for multi-project users. The hit/miss decision is unchanged. * test(runtime): type the worktree-meta stub against WorktreeMeta Main now rejects bare object parameters, and the merge picked that rule up. * fix(worktrees): wait on in-flight preparations and re-arm the pool on failed creates A create landing mid-checkout now claims the in-flight preparation and awaits it, as main did. The `checkoutFinished` filter and its `not_ready` miss reason made the create skip a prepared checkout that was seconds from done and pay a full cold add instead; on a 40k-file repo that turned a 0.2-1.5s create into 2.4-4.3s. The preparation's own git also runs at `status` again rather than `background`, so awaiting it does not park behind status pollers. Only the stale reclaim stays `background`, which no create waits on. The deferred pool re-arm now fires on every path, not just the success path. Main armed the replacement synchronously inside the consume, so a later failure in include copy, push-target setup, or terminal startup still left one warming. The thunk stays deferred until after terminal startup for admission ordering, but a `finally` on the desktop create and matching failure-path fires on the runtime create restore that guarantee. It fires exactly once. * refactor(runtime): carry the pool re-arm in one holder The runtime create used three mechanisms to guarantee the deferred pool re-arm fires: a catch in the git create, a catch on materialization, and a holder fired in the managed create's finally. The desktop create already used one holder for the same guarantee. The holder now threads down through the create args, so the git create arms it at the point it consumes a prepared checkout and nothing below has to handle the failure case. The thunk already re-checks the pool before arming, so a single fire point in the outermost finally covers every failure after the consume. Behavior is unchanged; both flipped failure-path tests still assert exactly one fire, and each fails without the production change. * feat(agent-status): add the canonical store and child-work contract (#20717) * feat(agent-status): add canonical shared store contract * fix(agent-status): harden canonical store invariants * fix(agent-status): close canonical store race windows * 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 pine7206f62a8from 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, fromc6a7216984toe7206f62a8. 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 * 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 * 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. * 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 (blob92dc5c644a, 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> * 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 * 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> * 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. * 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 * 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 * 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 baselinee7206f62a8, 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 pine7206f62a8with 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 * 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 * 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. * 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> * 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> * 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> * 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 inf34d08a452, which a later merge resolved in favour of main's side; the two added files survived, the deletion half did not. * 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. * 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. * 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. * 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 * fix(settings): preserve multiline proxy bypass rules (#20957) * 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 * 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. * 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 * 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. * docs: remove unavailable diff shortcuts (#20974) Co-authored-by: m4air <m4air@Mac.localdomain> * 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 * 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. * 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 * 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> * 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. * fix: keep activation inventory on its execution host * test: remove obsolete message type import after main integration --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Merge Sim <sim@local> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: OrcaWin <alpha-eng@stably.ai> Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: plotarmordev <plotarmordev@users.noreply.github.com> Co-authored-by: shahidbeig-a11y <258701601+shahidbeig-a11y@users.noreply.github.com> Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Lesley Murfin <lesley@revivebusiness.ca> Co-authored-by: BAEK'space <112856532+100space@users.noreply.github.com> Co-authored-by: JuuuuHong <juhang720@gmail.com> Co-authored-by: MumuTW <42820974+MumuTW@users.noreply.github.com> Co-authored-by: kaluli123123 <zengyan@88.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>