Repository navigation
refactor(mobile): send the task workspace-creation domain through typed RpcOperations - #20568
Conversation
…ore 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
`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
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
…ed 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
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
|
Too many files changed for review (240 files, 100 file limit). Bypass the limit by tagging |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (209)
🚧 Files skipped from review as they are similar to previous changes (84)
Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review. 📝 WalkthroughWalkthroughThe change adds typed RPC operations for task runtime, workspace creation, source search, sparse presets, SSH state, and setup-hook trust. Existing task callers now use request and interpret helpers instead of raw RPC calls. Workspace-create parameters now use the Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The typed RPC migration preserves existing behavior, and no actionable merge-blocking regression is established. 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 36 functions across 31 files. (209 skipped: 209 unsupported.) Full details: Description checkExplanation The description provides detailed scope, rationale, implementation notes, and test results, but it omits several required template sections. The Linked Issue is empty, and the required Resolution Complete the required template sections. Add a valid linked issue after Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the full #20568 diff at a25ae31b (230 files: ~30 production / test-support sources, plus ~200 recording goldens and pilot-scenarios.json).
- Four new operation modules —
mobile-workspace-create-operations.ts,mobile-workspace-source-operations.ts,mobile-task-runtime-operations.ts,mobile-task-source-search-operations.tsdefine 26 operations over 25 methods, each with an explicit acceptance policy, anafter-caller-barrierbarrier, and a reader. The twostatus.getreaders carry distinct named policies (hydration surfaces the host message, create-time probing degrades); no method gets two readers. - Call-site migration — hydration, create actions, source effects, sparse actions, SSH state, setup-hook trust, composer hosted-base resolve, Smart source paste/search, the create retry loop, and the capability probe now send through those operations. I traced each against its pre-refactor form: acceptance policy, reader projection (
presets/preset/state/uimember vs whole payload),stale-guard ordering, and therefDetails ?? refs.map(...)fallbacks all match. Refusals the old code surfaced viaresponse.error.messagethrow the same message throughrequire-result-or-throw-message, andisSuccess(x) ? … : …sites map tosuccess-result-or-skip. worktree.createdelivery-unknown identity —bindDeferredRpcOperation.requestreturns the transport promise itself, so theWeakSetmark survives to the retry catch. The two added tests inworktree-create-retry.test.ts(marked rethrow keeps identity; unmarked stays unmarked) can each fail if that identity is wrapped or re-marked.RpcSendArgumentsoptional-params branch — letspreflight.checkbe sent with no params, matching the bytes main put on the wire; the branch only fires when the send-params type has no required field.- Test-support wiring — the recording loader shares
rpc-delivery-ambiguityso VM-loaded operation modules observe oneWeakSet; new sender/hook mount adapters cover the migrated senders; the inventory ratchet drops the 12 migrated files and keeps a live site for each remaining entry.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
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
`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
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the delta on #20568 since the prior pullfrog review at a25ae31b (two commits, fe3868508c and b4757d3098): the new reader helper extraction and the newly recorded local agent-detection arm. I read the incremental diff, the full PR diff for the touched source, and the new fixtures.
- Named the one-property reader —
rpcUncheckedMemberReader(variant, key)inrpc-reader-payload.tswrapsrpcPayloadMember, and five hand-rolled readers (git commit-compare,ui.get,ssh.connect/ssh.getState, sparse preset list/save) now use it. Behavior-identical: samevariant, samecompatible: true/ empty-salvage result, andrpcPayloadMemberstill preserves the native property-read throw onnull/undefinedthat the commit-compare reader relies on to leave an already-loaded file list alone. - Recorded the local arm of agent detection — the SSH mount adapter split into
sshStateAdapter(connectionId?); a repo with noconnectionIddrivespreflight.detectAgentswith params omitted. The newtasks.workspace-ssh-localfamily adds pilot scenariotw-workspace-ssh-local-agentsplus thetw-workspace-ssh-local-agentsandmatrix-tasks.workspace-ssh-local-preflight.detectagents-1goldens. The golden pins the sent method and the params-absent call, so it fails if the adapter regresses to the remote arm. - Re-digested recorder hashes —
recorderSha256moved across ~200 goldens to match the changed recorder sources; test-only metadata.
The new scenario supplies a fulfilled ["codex","claude"] reply for preflight.detectAgents#1, so no REPLY_MATRIX_NORMAL_RESULT_INVENTORY entry is needed, and the family census picks the new family up automatically.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…d 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
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the delta on #20568 since the prior pullfrog review at b4757d30 (one commit, 3eb31cf9, test-support and goldens only). I read the incremental range-diff, the changed test-support sources, the new scenarios, and the new goldens.
- Made the create mount adapter resolve setup —
workspace-settings-mounts.tsextracts the old inline'settings.task-workspace'adapter into ataskWorkspaceAdapter(setupResolution)factory, so the previously hardcoded{ kind: 'prompt', command: 'setup', source: 'repo' }becomes a parameter. Itssubmitnow readsargs.itemwith the old linear item as fallback, so a scenario can supply the create source. A second registration,'settings.task-workspace-create', resolves setup to{ kind: 'decision', decision: 'inherit' }. - Drove
worktree.createto a recorded wire — the newsettings-task-workspace-create-linearandsettings-task-workspace-create-pr-start-pointscenarios runcreateWorkspacepast the disabled-agent check toworktree.create; the PR scenario also recordsworktree.resolvePrBaseand this hook's built params (start point, generated display name, agent fields). The old prompt family is left intact, so that branch stays pinned. - Added the
linear.listIssuesleg to the smart-search base — alinear-assignedstep afterbranch-refsmakes the base drivelinear.listIssues, which previously appeared only in a non-base scenario and so had no matrix partition. - Re-digested the goldens — 5 added, 5 moved beyond the digest, the remaining ~198 changed on
recorderSha256only.
I verified the factory is reachable (pilot-mount-adapters.ts spreads workspaceSettingsMounts), the new family is matrixed at both settings.get and worktree.create, and the new linear.listIssues matrix golden covers all refusal partitions. I could not execute the mobile recording suite in this environment (mobile dependencies are not installed — expo/tsconfig.base.json does not resolve), so coverage was established by reading the scenarios, adapters, and goldens rather than by running pnpm --dir mobile test.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…pters 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
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the delta on #20568 since the prior pullfrog review at 3eb31cf9 (one commit, 3332f7ac, test support and goldens only).
- Dropped three unreachable
unmountaction branches —task-workspace-hook-mount-adapters.tsno longer routesaction('unmount')tohook.unmount()intasks.workspace-source,tasks.workspace-sparse, or the SSH adapters. Teardown stays ondispose: hook.unmount, whichrun-recording.tsalready awaits in itsfinally. - Re-digested all 208 goldens —
recorderSha256is the only line that moved.recorder-digest.tshashes every non-markdown file under the recorder directory, so editing the adapter necessarily moves it.
I verified the unreachability claim end to end: the sole unmount producer is lifecycleSchedules, called only from family-recordings.test.ts for inventory-lifecycle, b3, settings-bot-overrides-fulfilled, settings-workspace-context-fulfilled, and settings-task-hydration-fulfilled. Their operations are workspace.file-inventory, linear.issue-detail, settings.bot-overrides, settings.workspace-context, and settings.task-hydration, so none mounts a tasks.workspace-* adapter. The schedule also appends a remount step those adapters never handled, so the branch could only have thrown had it ever fired. The one hand-written remount step in pilot-scenarios.json belongs to settings-task-hydration-refuse-after-data.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
#20563 (e53f155) re-recorded every golden and renamed the new-workspace-runtime-context named delta. Resolution: - pilot-scenarios.json: main's three namedDeltas renames plus this branch's 31 task-workspace scenarios; baseline repinned to e53f155 because main's 5ec0b26 is a pre-squash branch commit that is not reachable from main. - goldens: took main's 153 verbatim. The next commit re-records them at the new baseline. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Recorded from a detached checkout of e53f155 (main's unmigrated product code) with this branch's recorder laid over it, so the parity claim stays non-circular. - `baseline` repinned to e53f155 on all 208 goldens; main pinned 5ec0b26, 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
Takes main's re-recorded goldens (#20562: per-scenario scenarioSha256, recorder-dir-only recorderSha256, goldenFormatVersion 4) and repins the manifest baseline to 50e752f. Goldens are re-recorded in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Baseline repinned to 50e752f and 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
…ch 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 from 50e752f to main's tip c6a7216, `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 to c6a7216 under 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
…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 pin c6a7216 rather than moving to 6a11a0b. 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
#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 from 50e752f to main's tip c6a7216, `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 to c6a7216 under 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
…aders 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 pin c6a7216 rather than moving to 6a11a0b. 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
…rough 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 from 50e752f (#20562) to fc525c3 (#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 baseline fc525c3 in 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 baseline fc525c3 with 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 from c6a7216 in 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, from fc525c3 to main's c6a7216, 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 baseline c6a7216 with 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 baseline c6a7216 with 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
…ser 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 from 50e752f to main's tip c6a7216, `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 to c6a7216 under 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 at c6a7216 with 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 at c6a7216, 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 reverts 92e679f and 16c9c23 as 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 pin c6a7216 from 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 baseline c6a7216 in 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: 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 to 3f71999237. 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 of e53f1557e1 (main's unmigrated
product code) with this branch's recorder laid over it, so the parity
claim stays non-circular.
- `baseline` repinned to e53f1557e1 on all 208 goldens; main pinned
5ec0b2698f, 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 to 50e752fc66 and 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 c…
…ed RpcOperations (stablyai#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 of e53f155 (main's unmigrated product code) with this branch's recorder laid over it, so the parity claim stays non-circular. - `baseline` repinned to e53f155 on all 208 goldens; main pinned 5ec0b26, 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 stablyai#20562's per-scenario digest Baseline repinned to 50e752f and 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 stablyai#20562 was built to give. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…ch it, not the whole directory (stablyai#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. stablyai#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 stablyai#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 from 50e752f to main's tip c6a7216, `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 to c6a7216 under 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
stablyai#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. stablyai#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 stablyai#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 from 50e752f to main's tip c6a7216, `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 to c6a7216 under 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 stablyai#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
…aders through typed RpcOperations (stablyai#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 stablyai#20662's adapter seam The merge brought stablyai#20568's per-golden scenario digest and stablyai#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 pin c6a7216 rather than moving to 6a11a0b. 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
…rough typed RpcOperations (stablyai#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 from 50e752f (stablyai#20562) to fc525c3 (stablyai#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 baseline fc525c3 in 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 baseline fc525c3 with 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 stablyai#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 stablyai#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 from c6a7216 in 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, from fc525c3 to main's c6a7216, 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 baseline c6a7216 with 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 baseline c6a7216 with 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
…ser domains through typed RpcOperations (stablyai#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. stablyai#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 stablyai#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 from 50e752f to main's tip c6a7216, `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 to c6a7216 under 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 (stablyai#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 at c6a7216 with 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 stablyai#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 at c6a7216, 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 reverts 92e679f and 16c9c23 as 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 stablyai#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 pin c6a7216 from 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 stablyai#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 baseline c6a7216 in 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(mobile): give the page its height and its long press; C1.7 device proof (OTA phase C, C1.9) (#21589)
* fix(mobile): give the Route A document the height its mounted tree measures against (OTA phase C, C1.9)
The document this builder emits carries no stylesheet, so `html`, `body` and `#root` have no
height, and every box react-native-web lays out below the mount is `flex: 1` against a parent
that measures 0. The collapse is silent in every check that existed: the entry stamps `mounted`,
the route tree commits, `innerText` holds every row, and the accessibility tree reports each one
at the offset it would have had. Nothing is painted below the header, and nothing takes a tap —
the list sits inside a scroller the collapse clipped, and a phone reads it to VoiceOver while no
row responds. Lane C1.7 found it on both an iPhone 17 Pro simulator and a Pixel 9 Pro emulator,
and the same bytes reproduce it in headless Chromium.
The fix is the reset Expo's own web template ships for a react-native-web root, emitted inline
because the shell's CSP already allows `style-src 'unsafe-inline'` for the sheet react-native-web
injects at runtime; a linked asset would paint the collapsed layout until it landed.
The render check gains the assertion that would have caught it: the root's box measured against
the viewport, and the one control this route paints with no RPC answered — the New Workspace
button, positioned against the bottom of the root, which the collapse moved to y = -72 — asked
for by `elementFromPoint` at its own centre. Laid out is not reachable, so the check is a hit
test and not another read of the DOM. Without the reset it fails `expected +0 to be 844`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let the page have the long press WebKit was taking (OTA phase C, C1.9)
The shell's WKWebView is built with the default text interaction, so WebKit installs its selection
assistant over the page. A hold on a worktree row raises the selection loupe over the row's own
text and the touch is cancelled before the page's responder sees it, which leaves every
long-press action in the page dead on iOS while a tap works. Lane C1.7 measured it: the same
injected hold opens the row action sheet on the native list and on the page in the Android
WebView, and does nothing in the page on iOS.
It is not the document's to fix, which the device disproved one rule at a time:
`-webkit-touch-callout: none`, `-webkit-user-select: none`, and both together all left the loupe
and left the hold undelivered, and headless Chromium confirms the property computes to `none` on
the page's text, so the CSS reaches it and WebKit's own gesture wins anyway.
The cost is real and named here rather than discovered later: the page has no text selection on
iOS, so selectable `Text` — markdown, diff rows, file preview, chat — cannot be selected there
until a page-side copy affordance exists. Everything the shell already forbids is unchanged, and
Android is untouched.
No unit test: the module's Swift checks compile the seven WebKit-free logic files and never import
WebKit, so a `WKWebViewConfiguration` cannot be built in them. The device proof stands in.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the root reset the template's bytes, not a copy with an addition
The comment said the reset is what Expo's web template ships, and `margin:0` was not in it.
`@expo/cli@55.0.36/static/template/index.html` carries height, `overflow` and the root's flex box
and nothing else, and react-native-web emits `body{margin:0}` in the sheet it injects at runtime,
so the addition only covered the frames before that sheet landed. Nothing pinned it either:
removing it left all 60 tests green, which is the other way of saying it was never load-bearing.
Dropping it makes the string one thing with one source instead of a copy to keep in step with two.
The pins on the rest of the reset are unchanged, and so is the frame that mattered: the root still
has a definite height before the first paint, which is what the collapse needed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the render check with the root formatter
`config/scripts` is formatted by the root oxfmt, not mobile's, and CI checks neither, so a 102-char
line I added sat over the root's `printWidth: 100` with nothing to catch it. Reflowed by
`./node_modules/.bin/oxfmt --write` from the repo root; no behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the root reset shares with Expo's template, not that it is its bytes
"The bytes Expo's web template ships" is false and checkable: the template's own block is
pretty-printed with comments and trailing semicolons at 410 bytes, and this string is 112. What is
actually true, and what the next reader needs, is that it carries the same declaration set and the
same `id="expo-reset"`, minified.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin every rule of the root reset, not two substrings of itself
The check read the constant back against itself: `toContain(MOBILE_WEB_APP_ROOT_RESET)` plus two
substrings taken off that same constant. A rule dropped from it took the assertion with it, so
`body{overflow:hidden}`, `flex:1` and the `expo-reset` id were unpinned — and the render check
stays green without the overflow rule, so nothing else held them either.
Each rule is now a literal written here, named one at a time so a failure says which one went, and
the id is pinned beside them. Verified red-first: removing the overflow rule, the `flex:1`, or the
id each fails this test and only this test.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see (#21436)
* fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see
A local terminal-artifact grant pinned the file as `dev:ino:nlink:size:mtimeMs`.
On Linux every one of those can survive an unlink+recreate: ext4 reuses the
just-freed inode (measured: 100% of the time), nlink and size are unchanged for a
same-size replacement, and the mtime clock is tick-quantized to 1ms, so a swap
inside one tick produces a byte-identical identity string. The grant then served
the attacker's bytes as if nothing had changed.
Local grants now also pin a sha256 of the artifact's content, taken from the same
handle as the stat so nothing can swap the file between them, and every local
read, preview and write re-checks it before returning or committing content.
The stat identity string itself is unchanged: the relay recomputes it verbatim to
honour `expectedStatIdentity`, so its format is a wire contract. Remote grants
keep the stat-only check and are untouched.
This is also the mechanism behind the intermittent
`orca-runtime-files-terminal-artifact-io.test.ts` failure on
`rejects stale absolute terminal artifact previews before returning changed
content`: it replaces an 8-byte artifact with 8 different bytes, so whenever the
two writes shared a 1ms tick the product genuinely could not tell them apart.
* docs(runtime): record what the terminal artifact grant checks do not close
The digest makes the same-size swap detectable; it does not make the sequence
atomic. A reader arriving at the access module would reasonably assume otherwise,
so write down the measured limits of the stat identity, why the identity string
cannot change, and the four windows that stay open — the write path's surviving
rename() gap above all.
* fix(remote): reassert terminal geometry after reveal (#21568)
* fix(remote): reassert terminal geometry after reveal
* test(remote): expose layout queues through fixture
* test(remote): narrow layout target assertion
* fix(omp): submit large prompts in one PTY frame (#21573)
* fix(omp): join prompt submit with large paste
* test(omp): cover joined submit timing
* ci: rerun PR checks after timing test fix
* test(omp): acknowledge joined submit activity
* fix(mobile): let the shell's page paint a file preview (OTA phase C, C3.0) (#21591)
* fix(mobile): let the shell's page paint a file preview
A file preview has one shape on the wire: the desktop answers a base64 body
and `normalizeMobileFilePreviewResult` composes `data:<mime>;base64,<content>`
for React Native Web's `Image`. Under `img-src 'self'` the browser refuses to
load it, so every image preview in the page paints nothing — reproduced in the
render check, which logged the refusal naming `img-src 'self'` before this.
`data:` is granted to images and to nothing else, so what it admits is what the
page itself composed out of a reply it already holds; `script-src 'self'` and
`connect-src 'self'` are untouched, and `blob:` is not added because nothing in
the closure needs one. Both platform pins narrow from "the header contains no
`data:`" to "`data:` appears on `img-src` and nowhere else", which is the check
that still fails if a later directive grows one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the data: image case actually loads through
Round 1 is right on both counts. In react-native-web 0.21.2 the hidden <img>
the Image component renders carries `alt`, `style`, `draggable`, `ref` and
`src` and no load handlers at all — it is there for the browser's image context
menu and for `getBackgroundSize()`. The load signal comes from
`ImageLoader.load`, which is `new window.Image()` with `onload`/`onerror` on it,
so the `new Image()` in this case is the same mechanism the screen's own load
runs through rather than a stand-in for it.
And the screen maps `onImageError` to "Unable to load preview"
(`MobileFilePreviewScreen.tsx:282`); "Binary preview unavailable" is the
normalizer's `binary_file` branch, which a CSP refusal never reaches.
Comment only. No assertion moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state the img-src data: bound as the destination, not provenance
"admits only what the page itself built" read as a provenance guarantee, and CSP
has none to give: `data:` is matched as a scheme, so the directive admits any
`data:` image URL and the browser cannot tell one the page composed from one it
was handed. Nor is the content the page's own — the mime type and the base64
body both come from the host, and `normalizeImagePreviewResult` only checks the
mime type is a non-empty string.
The true bound is where the URL goes: it is never fetched as anything but an
image, `img-src` is the only directive admitting it, an image fetch executes
nothing (an SVG inside an `<img>` runs no script), and `script-src 'self'`,
`connect-src 'self'` and `object-src 'none'` are untouched.
Both copies reworded identically, since they are kept in step by the render
check's own policy comparison.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* Update README downloads badge
* fix(claude): stream transcript ancestry proofs
Stream transcript ancestry proofs without loading whole histories into memory.
* fix: read crash diagnostics without loading whole dumps into memory
Read crash diagnostics incrementally to avoid whole-dump memory spikes.
* ci: cache cloud workspace packages in unit tests
Include the cloud lockfile in the unit-test dependency cache key so repeated runs reuse cloud package downloads.
* fix(editor): share code-fence boundaries for Markdown links (#21574)
* fix(editor): share fence boundaries for Markdown links
* test(editor): assert highlighted lines across Monaco token spans
* perf(editor): scan fence lines without substring allocations
* fix(editor): match fence suffix whitespace rules
* ci: isolate cloud relay tests from unit shards
Run cloud relay integration tests once and exclude them from general shards, while caching the cloud lockfile inputs.
* fix(omp): acknowledge completion delivery and retire stale retries
* fix(omp): acknowledge completion delivery and retire stale retries
Co-authored-by: Tim Maximilian Lucas <7103424+timaxlucas@users.noreply.github.com>
* test(omp): exercise completion recovery over native HTTP in platform CI
* test(omp): verify rendered status clears after completion retry
---------
Co-authored-by: Tim Maximilian Lucas <7103424+timaxlucas@users.noreply.github.com>
* fix(omp): load task drafts through the status extension
* fix(omp): load task prefill through the status extension
* test(omp): capture installed runtime task prefill in hidden Orca
* fix(omp): resolve transcripts from active XDG and profile roots (#20633)
* fix(omp): resolve XDG session root fallback
* fix(omp): share profile-aware transcript root resolution
* test: verify actual OMP XDG transcript discovery and reading
* test(omp): satisfy reader smoke loop lint
* test(omp): resolve vault fixture paths without redundant cache
* fix(omp): refuse normalized Windows drive-relative roots
* fix: check host ownership before recovering OMP sessions (#20628)
* 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 to 3f71999237. 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 of e53f1557e1 (main's unmigrated
product code) with this branch's recorder laid over it, so the parity
claim stays non-circular.
- `baseline` repinned to e53f1557e1 on all 208 goldens; main pinned
5ec0b2698f, 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 to 50e752fc66 and 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…

Step 4, domain 2, first half. Same process as the pilot (#20544): goldens recorded against main before the refactor, the refactor commit moves zero goldens, matrix goldens after.
Scope: 12 of 37 files / 36 of 109 references in
mobile/src/tasks/— the workspace-creation seam (worktree.createand its retry, PR/MR base resolution, SSH connect/state, agent detection, repo hooks, sparse presets, ref search, runtime hydration, the Smart picker's provider reads). The provider item/detail/mutation half (25 files / 73 refs) is a declared holdout for the next PR; the two halves share no fixtures, and noPromise.allgroup straddles the seam. Three of those 25 cannot reach zero even after migration:mobile-tasks-source-family.test-support.ts(matches the literal in a source scanner) and two hooks that multiplex{method, params}steps, so the method is a runtime value.What
26 operations over 25 methods in four modules named for what they send:
mobile-workspace-create-operations.ts,mobile-workspace-source-operations.ts,mobile-task-runtime-operations.ts,mobile-task-source-search-operations.ts. (The refactor commit message says 22/20; 26/25 is the real count.) One shared reader (sshConnectionStateReader, two operations) and one-key member reads through the newrpcUncheckedMemberReader(variant, key)inrpc-reader-payload.ts(which also replaced the pilot's longhand twin); no method has two readers. One method carries two named policies:status.get— hydration surfaces the host's refusal, create-time capability probing degrades to "no capabilities" and creates anyway. Zero new acceptance policies.WorkspaceCreateParamsis nowRpcSendParams<'worktree.create'>instead ofRecord<string, unknown>. One transport change:RpcSendArgumentslets params be omitted when the method's params type has no required field, becausepreflight.checkis such a method and main sent it none — forcing{}would have put a new object on the wire.Three sites read the raw refusal envelope before interpreting because the code or message decides the route: the create retry needs the message for
isRetryableWorktreeCreateConflict, the paste lookup needsmethod_not_foundto retire the slug probe host-wide. The hydrationPromise.allkeeps raw requests inside and interprets after thestaleguard, because main's group rejects on the first leg and a barrier would wait for the slowest peer.Delivery-unknown at
worktree.createrequestreturns the transport promise itself, so the retry loop catches the object the transport marked. Two new tests inworktree-create-retry.test.tspin it both ways:toBe(marked)withisRpcDeliveryUnknowntrue, and an unmarked rejection stays unmarked — a mark added on the way out would replay a create the host never received. The goldens pin it too:tw-create-retry-ambiguous-after-droprecords the create pending at the reconnect wait and abandoning at exactlysettledAt: 20000.Evidence
git show --name-only f5f9f7e1c0 | grep -c rpc-foundation/goldens→0. Recording suite green on both sides of the commit.src/tasks/37 files / 109 refs → 25 / 73; 12 entries deleted. Ratchet's stale/grown/overstated checks pass.worktreeCreateCapabilityRead's policy → 4 failures; interprettaskUiStateReadinside the request chain instead of after the stale guard → 1 failure; wrapworktree.create's rejection in a newError→ 13 failures (4 goldens + 9 unit tests incl. both identity tests).preflight.detectAgents— the SSH fixture hardcoded a remote connection;worktree.createfrom the create drawer — the fixture's disabled-agent check threw before the call;linear.listIssues— only in a non-base scenario). Each got a scenario recorded at the baseline; repointing any of the 26 methods or swapping any refusal policy now fails at least one test (the one remaining policy survivor istaskSettingsWrite, a fire-and-forget whose reply no caller interprets).Measurements
52 min start to green for the migration itself · goldens 153 files / 2.9 MB → 208 / 3.8 MB · harder than source-control only in that scenarios must be recorded before any product edit, so the seam has to be chosen up front; easier because the delivery-unknown contract already held.
Several commits are re-digests (manifest edits move
recorderSha256on every golden today); #20562 removes that. Merged main once after #20563 landed:baselinerepinned toe53f1557e1(main's squash; the5ec0b2698fmain pinned is a branch commit the squash left unreachable), all 208 goldens re-recorded at that baseline from main's unmigrated code, main's 153 golden bodies unchanged, the 55 task-workspace goldens the same set as before, and the refactor still replays every golden green. #20562 has since landed; main was merged a second time,baselinerepinned to50e752fc66, and all 208 goldens re-recorded under the per-scenario digest. OnlyrecorderSha256andbaselinemove on main's 153 goldens;scenarioSha256moves on none, which is the property #20562 was built to give.Gates
pnpm --dir mobile typecheck0 ·pnpm --dir mobile test581 files, 5023 passed / 6 skipped ·pnpm tc0 ·check:code-quality:changed0 over 31 files ·check:react-doctor:changedclean ·oxfmt --check .frommobile/clean ·oxlint .frommobile/0.