Commit Graph
11002 Commits
Author SHA1 Message Date
Jinwoo Hong 2dfdbc8657 refactor(mobile): send the task provider, detail and board domains through typed RpcOperations (#20685)
* test(mobile): record main's task provider item, detail and board RPC behaviour

35 scenarios over 22 of the 25 files left in src/tasks/, recorded from main so the step-4
migration of the provider half has a frozen answer to compare against. Every one of the 70
references this branch will migrate reaches a recorded wire here, which is the check the
workspace-creation half added after it lost three sites to fixtures that short-circuited
before the call.

Scenario params are observed, not written: a generator drove each adapter with nothing
answered, read the projected sender calls back, and emitted the completion steps from them,
so no `params` in the manifest is a guess about what the screen sends.

Five adapter modules, split the way the screens are: one item's reads, the list and composer,
the item mutations, the board's reads and the board's row mutations. `mountModelHook` holds
the mount/dispatch/project boilerplate these twenty-two hooks share, so each adapter is only
its fixture, its actions and its projection. Two fixture modules hold the task items and the
project rows, shared so the same pull request looks the same to the comment hook, the merge
hook and the checks hook — which is what makes their recordings comparable.

`baseline` moves from 50e752fc66 (#20562) to fc525c355d (#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 fc525c355d 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
fc525c355d 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 c6a7216984 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 fc525c355d to main's c6a7216984, 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 c6a7216984 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 c6a7216984 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
2026-09-15 15:03:33 -04:00
Jinwoo Hong 98784820d8 refactor(mobile): send the transport pairing and status domain through typed RpcOperations (#20667)
* test(mobile): record the transport pairing and status domain against main

Adds seven recording families for `mobile/src/transport/`, recorded from main's
unmigrated product code before any refactor: the protocol-gate hook, the retrying
capability probe, the pairing candidate race, credential rotation, direct-to-relay
upgrade, startup pairing recovery and first pairing.

The relay modules build their `defaultDependencies` at module scope, so merely
referencing `Platform.OS` or a storage-backed loader threw before an adapter could
override it. `native-mounting-substitutes.ts` separates reference from use: react,
zod and @noble/hashes are the real libraries, expo-crypto routes through the Web
Crypto the scheduler already pins, and the two secret stores throw when called.

`baseline` repins to fc525c355d because main's tree no longer matches the pinned
50e752fc66. All 241 goldens re-recorded; main's 208 move only `baseline` and
`recorderSha256`, and no `scenarioSha256` or recording body moves.

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

* refactor(mobile): send the transport pairing and status domain through typed RpcOperations

Migrates 11 of the 18 raw-port references in `mobile/src/transport/`: the protocol
gate, the runtime capability probe, the pairing candidate race, credential rotation,
the direct-to-relay upgrade, startup pairing recovery and first pairing.

Three operations over three methods, zero new acceptance policies. `status.get` gains
a third named policy — `status.transport-probe-or-skip` — because all three transport
callers treat a refusal as an absent answer, which is the Tasks create-drawer policy
but not the Tasks hydration one; transport cannot import tasks, and the name is what a
decode failure reports. `pairing.provisionRelay` and `pairing.getEndpoints` are
`require-result-or-throw`, which reuses the same `code: message` text the four call
sites each spelled by hand. Three sites read the raw envelope for `method_not_found`
before interpreting, because an unknown method means "this build has no relay" rather
than "the install failed". Reply parsing stays at the call sites: the readers are
unchecked and the zod contract schemas run where they ran before.

No wire change and no behaviour change: all 241 goldens replay green and this commit
touches none of them.

Two files stay on the raw port and now carry their own reason in the inventory.
`pairing-relay-candidate.ts` decorates a PairingCandidateClient with director recovery,
so it implements the port rather than calling it; its one chosen method string now
comes from hostStatusProbe. `mobile-runtime-capability-negotiation.ts` sends over the
physical clients' pre-`connected` authenticated path, which no recording can reach.
`runtime-capability-probe.ts` drops to its parameter type alone.

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

* test(mobile): pin the capability probe's cutover re-ask against the frozen baseline

The reply matrix drives every scripted reply, so it already covers a transport
rejection at each migrated site. It cannot cover the one signal the probe reads out
of a rejection: `isLogicalClientCutoverError` chooses a 250 ms re-ask over the 1 s
failure backoff, and no golden made that choice observable.

`transport-capability-probe-cutover-reasks-fast` migrates the logical client mid-probe
and binds the replacement request at exactly 250 ms, so a re-ask moved in either
direction fails the binding rather than recording a different number. The recorded
rejection carries `LogicalClientCutoverError`, its `Connection closed` cause and
`isRpcDeliveryUnknown`.

Recorded from a `git archive` of the pinned baseline with this branch's recorder laid
over it, per the recorder README: the migrated tree can no longer satisfy the fence.
The same run reproduced the other 241 goldens byte-for-byte.

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

* refactor(mobile): read the pairing sends top to bottom

Five pairing sites nested `parse(interpret(await request(...)))` three deep with the
await innermost, so the send was the last thing a reader found. Bind the reply first
and interpret it on its own line, which is the shape main had before the migration.

No behaviour change: the request still settles before interpretation and the barrier
is unmoved, so no golden shifts.

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

* test(mobile): make an unlisted native member throw instead of recording undefined

Three substitutes were plain object literals standing in for whole modules, so any member
the table did not list read as `undefined` rather than failing — the opposite of what the
file's own default proxy does, and a silent one: `react-native` alone is requested 354
times across the goldens, and `platformSelect`, `view` and `styleSheet` all read undefined.
A recording that reaches an unsubstituted native member is not evidence of anything,
because the product on a device would call it.

Also drops the `expo-secure-store` substitute, which no recording requests, and corrects
the doc's "fails loudly" claim: the throw is real, but `host-app-version-store.ts` catches
it and degrades to its unread state, which is what it does on a device too.

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

* test(mobile): keep one credential hash and only the fixtures someone reads

Nine fixture constants and `relayEndpoint` were exported with no reader outside their own
module, and `credentialHash` was copied verbatim into both relay adapter modules. The
adapter seam forbids one module under `adapters/` from importing a sibling, so the shared
form has to live with the fixtures both already import.

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

* test(mobile): re-record every golden at main's tip

Repins `baseline` to 6a11a0b8e6 and records all 242 from a detached worktree of that
commit with this branch's recorder overlaid, per the README's migration-branch procedure.

Two header fields move and nothing else: `baseline`, and `recorderSha256` for the 208
goldens main also carries, because this branch adds `native-mounting-substitutes.ts` and
`relay-pairing-fixtures.ts` to the engine and teaches the loader to consult the first.
Against the pre-merge tree every one of the 242 is header-only, so no recorded byte moved.

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

* test(mobile): let the interop marker through the native substitute proxy

`import * as ExpoCrypto from 'expo-crypto'` transpiles to an interop helper that reads
`__esModule` before copying members, so throwing on it fails the module system's own probe
rather than an unsubstituted API read. Four relay families could not record.

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

* test(mobile): read the substitute through Reflect instead of a cast

The changed-code casting gate rejects `key as string`, and the trap has a typed read that
needs no assertion.

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

* test(mobile): re-record for the substitute's typed read

`recorderSha256` only; every recorded byte is unchanged from the previous re-record.

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

* test(mobile): restore main's baseline pin and re-record

The merge brought main's pin c6a7216984. Repinning it to the branch's own
merge-base rewrote the `baseline` header of all 208 pre-existing goldens for no
behavioural reason, so put main's pin back and re-record at that commit through
a detached baseline worktree with this branch's recorder overlaid.

Only `baseline` moved in all 242 goldens; no recorded frame, settlement or
sender line changed. Against origin/main the 208 pre-existing goldens now differ
in `recorderSha256` alone, which this branch's two added engine files force.

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

* test(mobile): let a default import of the secret store reach its members

The store substitute answered every key with a throwing function, including
`__esModule`. TypeScript's `__importDefault` reads that marker and, finding it
truthy, binds the default import to the trap's own function instead of the
module record, so all five consumers saw `AsyncStorage.getItem` as undefined.
A recording that reached `host-app-version-store.ts` failed with
`TypeError: AsyncStorage.getItem is not a function` rather than the named
`Native store reached during recording: ….getItem`, and an `await import()` of
the store rejected on `.then` for the same reason. Both traps now leave the
marker undefined, and the doc states that invariant.

Also drop `recordingRandomBytes`: its body and the `expo-crypto` substitute's
are the same call into the seeded `getRandomValues`, and both relay entry
points already default `randomBytes` to `ExpoCrypto.getRandomBytes`, so the two
injections were passing the default back to itself. Its orphaned doc comment,
left stacked above `credentialHash` by the dedupe commit, goes with it.

The new suite does not record, so `recorderSha256` does not pin it.

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

* test(mobile): re-record for the secret-store and randomBytes fixes

`recorderSha256` moved in all 242 goldens, and `adapterSha256` in the 10
`relay.credential-rotation` and `relay.direct-upgrade` goldens whose adapter
stopped injecting `randomBytes`. Nothing else moved: no recorded frame,
settlement, effect or sender line differs, which is the claim that the default
the adapter was passing back to itself and the substitute it resolved to were
always the same function.

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

* test(mobile): adopt this branch's recorder digest in main's new goldens

Only the 47 goldens #20705 added moved, and only on `recorderSha256`: they
carry main's engine digest, and this branch adds two files to the engine. The
other 242 came back byte-identical, so the merge changed nothing any of them
observed. No recorded frame, settlement, effect or sender line differs anywhere.

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

* test(mobile): record the pairing race's decision and name its scenarios truthfully

The race adapter returned the raw `PairingCandidate`, whose `client` is a live
object the recorder cannot serialize, so `captureValue` threw and every
pairing-race golden baked an `Unsupported observation: function`
unhandled-rejection effect and left the race settlement `pending`. It now
settles on `winner.path`, which is the entire decision, and rethrows so the
both-refused rejection keeps its identity.

`transport-pairing-race-direct-wins` also did not record a tie. The runner
flushes after every step, so the two completions can never land in one
microtask and the scenario only ever exercised relay completing first. Rather
than change the engine to script a simultaneous delivery, the scenario and its
checkpoint are renamed to what they record, and a mirror scenario completes
direct first. The two matrix checkpoints inherit the base's name, so they follow.

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

* test(mobile): re-record the merge's headers and the repaired pairing race

66 goldens moved, in four groups:

- 51 from #20668, `recorderSha256` only and zero non-header lines: they carry
  main's engine digest and adopt this branch's.
- 10 other `transport-status` goldens, `adapterSha256` only and zero non-header
  lines: their adapter module changed, their recordings did not.
- `transport-pairing-race-relay-wins-when-direct-refused` and the two
  `matrix-transport.pairing-race-*` goldens: the race settlement is now
  fulfilled with `'relay'`/`'direct'` instead of pending, and the
  `Unsupported observation: function` unhandled-rejection effect is gone. The
  two matrix goldens also move `scenarioSha256`, having inherited the base
  scenario's renamed checkpoint.
- `transport-pairing-race-direct-wins` is renamed to
  `transport-pairing-race-relay-completes-first`, and
  `transport-pairing-race-direct-completes-first` is new.

`transport-pairing-race-both-refused` did not move: its race rejects, and a
rejection was always serializable. `recorderSha256` moved nowhere except in
those 51, which is the proof the engine is untouched by this step.

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

* test(mobile): name the module the throwing-on-call substitute stands in for

The paragraph called it "the secret store", which is `expo-secure-store` — not
in the table, and handled by the loader's throw-on-read default. The substitute
that answers with throwing functions is async storage.

This is an engine file, so the next commit re-digests every golden.

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

* test(mobile): re-digest every golden for the substitutes doc reword

`recorderSha256` in all 341 and nothing else: the reworded paragraph is a
comment in an engine file, so it moves the digest without moving a recording.
No frame, settlement, effect or sender line differs anywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 14:45:42 -04:00
Jinwoo Hong 44268d9616 refactor(mobile): send the github.* PR surface and the diff-review loaders through typed RpcOperations (#20668)
* test(mobile): record main's github.* PR and diff-review loaders before migrating them

Scenarios and goldens for the step-4 `src/session/` first half, recorded
against main's unmigrated product code so the migration that follows has a
frozen parity oracle instead of an assertion.

- 20 scenarios over seven new families: the seven `github.*` PR reads, the
  twelve PR mutations split by their three reply contracts (`{ok}` envelope,
  bare boolean, slug-addressed comment edit), the triage createTerminal+send
  launch, the PR branch-context chain and the review screen's three loaders.
- Two new sender-style mount adapters. Both mount exported async functions
  taking a client, so no React host is needed and the recorded state is each
  wrapper's own outcome.
- 50 new goldens: 20 pilot, 30 reply-matrix sites. `recorderSha256` moved on
  all 153 existing goldens because the adapters are in the whole-recorder
  digest; no other line in any of them changed.

Text diffs are deliberately unscripted: highlighting one reaches `lowlight`,
which the module loader refuses as an unspecified native dependency.

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

* refactor(mobile): send the github.* PR surface and the review loaders through RpcOperation

The step-4 first half for `src/session/`: eight files, 38 references to the raw
request port, all replaced with declared operations. No behaviour change — the
50 goldens recorded in the previous commit do not move, which is the claim.

- 21 operations over 21 methods. The seven PR reads keep their defensive
  parsers as readers; the ten status-envelope mutations share one reader
  because the `{ok, error}` convention is one host convention, not ten; the two
  bare-boolean mutations read the payload unchecked because `=== true` is the
  caller's confirmation rule.
- Four second readers, each justified in place: git.status and git.branchCompare
  for the PR branch context (a refusal costs a fallback, not the screen),
  git.branchCompare and git.branchDiff for review (the projection is not a
  superset of the verbatim payload), and worktree.show for the review notes the
  summary reader drops.
- Every failure text is preserved, including the two main kept apart: a refusal
  with no message falls back to the screen's copy, a transport drop with no
  message surfaces its empty message verbatim. `sendRaw`'s callers replaced
  theirs a second time, so those fall back on both paths.
- No retry, and no operation reads a dropped reply as a failed mutation: the
  rejection reaches each wrapper's catch as the original object.
- `github-pr-mutations.ts` split along the action/comment seam it already had
  in its consumers, so no file needs a max-lines bump.

Inventory: src/session/ 47 files / 114 references -> 39 / 76.

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

* test(mobile): record the review snapshot answering its notes leg first

The barrier mutation census found one survivor: moving
`reviewWorktreeMetadataRead.interpret` inside the `Promise.all` in
`loadMobileDiffReviewSnapshot` changed nothing any golden observed. The base
scenario answers the branch-base legs before the notes leg, so by the time the
notes reply lands the compare leg has already sent `git.branchCompare` and the
two orders record the same sender list.

This scenario answers the notes leg first, while the compare leg is still
resolving its base ref, and checkpoints before the rest. At that checkpoint the
barrier is the whole difference: the correct order has nothing settled, the
early interpretation has already rejected the action. The mutation now fails it.

Recorded from a detached checkout of the previous commit, which carries main's
unmigrated product code with this branch's recorder over it, so the parity claim
stays non-circular. One new golden; no existing golden moved, because the family
base is unchanged and `scenarioSha256` is per golden.

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

* refactor(mobile): bind one git.status projection reader, not a copy per domain

The branch-context read declared its own `statusProjectionReader` with the same
parser, the same 'normalized-status' variant and the same empty salvage as
source-control's `gitStatusProjectionReader`, while its doc block claimed "one
reader serves both". Export the source-control reader and bind it here so the
claim is true; the doc now names the reader and keeps the part that is actually
different, which is what a refusal means on each policy.

No wire change and no golden moves: the reader is the same function value the
copy computed.

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

* refactor(mobile): undo the github-pr-mutations split, which max-lines no longer forces

The split was made when the migrated file measured 319 lines. It does not any
more: `sendRaw`, `sendGithubPrMutation` and `extractMutationError` moved to
github-pr-mutation-outcome.ts and the prRepo/headSha allow-lists to
github-pr-repo-slug.ts, so the merged file is 293 lines against the 300 limit
and oxlint is clean.

Nothing imported github-pr-comment-mutations directly — every consumer went
through the re-export hub in github-pr-mutations — so the seam bought a reader
one extra file to open and nothing else. Merge it back and drop the hub.

Product-only: same wrappers, same params, same settle shapes, no golden moves.

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

* refactor(mobile): one settleable-operation type for the PR reads and mutations

`GitHubPrMutationOperation` and the private `GitHubPrReadOperation` declared the
same two members for the same reason: a settle shape needs a bound operation's
method and its interpret, nothing else. Keep one, `GitHubPrSettleableOperation`,
and import it into the read settle. `extractMutationError` goes back to private,
as it was on main; it never had an importer outside its own file.

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

* docs(mobile): drop the key-order claim from the PR param builder

The oracle does not observe param key order: `captureValue` in recording-values.ts
sorts keys, and no golden carries a raw frame string, so "the sender recordings
pin the bytes" was not a fact the evidence supports. The assertion stays for the
reason already in the doc — the builder is method-generic and returns a record.

`GitHubPrParamOptions` goes back to private; nothing outside the module names it.

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

* refactor(mobile): read the bare-boolean mutations with the shared unchecked reader

`mutationConfirmationReader` spelled out what `rpcUncheckedPayloadReader` already
returns, under the same 'pr-mutation-confirmation' variant that eleven other
operations in this tree get from the helper. Same function value, same variant,
so no golden moves. The comment explaining why the payload is left unread stays.

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

* refactor(mobile): one RpcOperationSender for both domains, not one alias each

`MobileSessionRpcSender` and `MobileSourceControlRpcSender` were the same type
with the same doc, each derived from whichever operation its domain happened to
own. Replace both with `RpcOperationSender` in transport, derived from
`settingsRead` there, and name it for what it is: what a bound operation needs
to send with.

Still derived rather than restated, so no module names the raw request port to
accept a client; the port inventory and its ratchet are untouched.

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

* test(mobile): point the moved PR and diff-review adapters at the seam and register them

The merge commit carried the two adapter files into adapters/ with their old
specifiers and left the register untouched, so this completes the move: the
relative imports climb one more level, and both modules are registered in
adapters/mounted-operation-modules.ts as identifiers imported from their own
source, which is what adapter-seam.test.ts checks.

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

* test(mobile): re-record the session goldens against #20662's adapter seam

The merge brought #20568's per-golden scenario digest and #20662's per-golden
adapter digest, so the 51 goldens this PR owns move on four header fields and
nothing else: baseline, goldenFormatVersion, recorderSha256, and the newly
added adapterSha256. No recorded byte outside those headers changed.

baseline stays at main's own pin c6a7216984
rather than moving to 6a11a0b8e6. 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
2026-09-15 14:29:15 -04:00
github-actions[bot] f742ab88d2 Update README downloads badge 2026-09-15 18:29:06 +00:00
Brennan Benson 9ab0a18e82 refactor(agent-status): isolate legacy status ingress behind one admission point (#20716)
* refactor(agent-status): isolate legacy status ingress

* fix(agent-hooks): move advertised-capability source onto the ingest envelope

ingestRemote() gained a third positional argument in this PR
(advertisedAgentStatusCapabilities) to satisfy a new ratchet requiring
every legacy-ingress call site to name its capability source. Both
production callers pass the same constant every time, so the argument
carries zero runtime information — but Vitest's toHaveBeenCalledWith
matches argument count exactly, so the pre-existing SSH relay
integration test (which asserts a 2-argument call) started failing
even though nothing about the actual admission decision changed.

Capabilities are a property of the producing peer/connection, not an
orthogonal call parameter, so move the field onto the envelope object
instead of adding a third positional argument: ingestRemote reads
envelope.advertisedAgentStatusCapabilities (defaulting to the
unadvertised-legacy-peer set), and both call sites stamp the constant
onto their envelope literal. Call arity stays at two arguments, so the
pre-existing evidence test needs no change.

The envelope never crosses the wire in either caller: SSH rebuilds it
field-by-field from the RPC params, and the WSL path copies (never
mutates) the wire-deserialized notification before stamping the field
on, so this is purely an internal main-process shape change.

Also strengthens the ingress ratchet test that required this: it
previously only checked that the capability constant's name appeared
somewhere in each caller's source, which a stray unused import could
satisfy. It now asserts the actual
`advertisedAgentStatusCapabilities: AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES`
key:value binding is present.
2026-09-15 10:26:44 -07:00
Jinwoo Hong 36ef93a64f refactor(mobile): migrate the small domains onto RpcOperation (step 4) (#20705)
* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's

`recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR
that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its
merge with main conflicted on that one line in 153 files; every future domain PR would collide
with every other in flight the same way.

Split the directory at a real seam instead of a filename convention: `adapters/` holds one module
per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers
the engine only. A new `adapterSha256` covers the source of the module that mounts each operation
a golden's scenarios drive, read off the same `mounts` calls that build the table the recording
runs against, so the pin cannot name a file the runner did not use.

Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the
goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file
inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file,
and an adapter importing a sibling each fail.

The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which
leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new
header field; the goldens re-record in the next commit.

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

* test(mobile): re-record the RPC goldens under the split recorder/adapter digest

Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers
`adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and
recording ran against the same pinned product tree.

    git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
      | grep -vE '^(\+\+\+|---)' \
      | grep -vE '^[+-]  "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l
    0

The seven `adapterSha256` values partition the 153 goldens by the module each was recorded
through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory,
9 tasks, 8 workspace settings.

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

* refactor(mobile): stop pinning goldens to recorder inputs no recording can read

The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the
per-family mutant registry beside it, and the probe-hole witness. None can change a recording --
the loader consults a mutant only when a mutant test asks for one, and no suite but the two
recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have
and charged every domain a full re-record for it.

`mutants/` now holds the table, the registry, the reference states, the mutant suites and the
probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can
reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by
name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts`
checks exactly that, and fails if an engine file names the directory or anything outside imports
from it.

`recorderSha256` also pins only the suites in `recording-drivers.ts`, which
`scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or
writes one to a scratch directory, is no longer provenance for a recorded file.

`OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold
the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own
exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden.

Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to
`root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed
anything. Each call now spells the root differently.

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

* test(mobile): re-record the RPC goldens under the mutant and driver exclusions

Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153
because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that
module now carries its own exposure declaration.

    git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
      | grep -vE '^(\+\+\+|---)' \
      | grep -vE '^[+-]  "(recorderSha256|adapterSha256)":' | wc -l
    0

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

* fix(mobile): restore the preferences actions the merge resolution dropped

#20568 added `resume` and `trust` actions to the `settings.task-preferences`
adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already
moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the
`pilot-mount-adapters.ts` conflict in favour of the registry merge silently
discarded them and `tw-task-preferences-resume-write` failed to record at all
("Missing or completed request: ui.set#1").

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

* test(mobile): re-record the RPC goldens at main's tip after the merge

All 208 goldens, header-only. `baseline` moves from 50e752fc66 to main's tip
c6a7216984, `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
c6a7216984 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
2026-09-15 13:10:32 -04:00
Neil caa465d1da fix(automations): stop tick latency counting against the missed-run grace (#20819)
* fix(automations): stop tick latency counting against the missed-run grace

The scheduler compared wall-clock lateness straight against the grace budget,
but evaluation runs on a fixed 60s interval that is never aligned to an
occurrence. With grace 0, any tick arriving after the scheduled instant -- in
practice every tick -- recorded skipped_missed and told the user "Orca was
unavailable during the missed-run grace window" while Orca had been up the
whole time. A zero-grace automation effectively never ran.

Grace is a downtime catch-up budget. An occurrence that came due while the
scheduler was running was never missed; it is waiting for the next tick. The
service now tracks continuous availability and only charges lateness to grace
for occurrences that came due while it was stopped.

Downtime behaviour is unchanged, and the new test asserts that half too.

The missed-run branch moved to dispatch-refusal.ts, which already owns
non-dispatch outcomes, keeping service.ts under max-lines without a disable.

Fixes #11299

* fix(automations): use a tick-latency tolerance instead of process liveness

Review caught two real defects in the first cut:

- availableSince is process liveness, not continuous execution. A suspended
  process (system sleep) keeps its start time, so an occurrence that came due
  during a multi-hour sleep skipped the grace check entirely and replayed on
  wake -- exactly the downtime case grace exists for.
- The restart edge: an occurrence due after the last tick but before stop()
  was reclassified as downtime and skipped with zero grace.

Elapsed lateness cannot be faked by suspension and needs no restart
bookkeeping, so the budget is now grace + two tick intervals. Both edges
disappear rather than being special-cased.

Also fixes a hollow test: workspaceId 'wt1' has no worktree separator, so the
target refused and the run recorded skipped_unavailable -- a 'not
skipped_missed' assertion passed without ever dispatching. Tests now use a
valid id and assert 'dispatching' directly, and cover the sleep, tolerance
boundary and restart cases.

* fix(automations): scope to the verified tolerance and document the stall gap

Review found three defects, all real:

- The 'as never' cast failed the changed-code casting gate. AutomationRendererChannel
  is a Pick<> precisely so a test can pass the real shape; cast removed.
- The restart test never restarted: evaluateAt advanced 60s internally, so the
  first pass already dispatched and the second was a no-op. It now evaluates
  exactly once and asserts no run exists before the second pass.
- tickMs * 2 does not bound a pass that holds the re-entrancy guard across a slow
  serve-mode dispatch.

I tried a busy-window fix for the third and could not test it honestly -- the
case needs a genuinely slow in-pass dispatch, and both attempts passed with the
fix disabled. Rather than ship logic I cannot prove, the tolerance stays at the
verified shape and the gap is documented where the next reader will find it,
with the reason 'time since last pass' is the wrong bound (a suspended process
runs no passes either).

Not a regression: on main that automation never ran at all.

* fix(automations): name the check for what it does and correct its message

Two review points, both fair:

- missedDuringDowntime consulted nothing about availability once the liveness
  flag was removed; it is elapsed lateness against grace plus tolerance.
  Renamed missedBeyondGrace so callers read the real contract.
- The run error still claimed 'Orca was unavailable' -- the same false statement
  #11299 was filed about, now reachable for a genuinely late run rather than a
  merely tick-delayed one. It states what was actually observed instead.

Also documented the deliberate trade CodeRabbit raised: elapsed lateness cannot
tell a short outage from a late tick, so a zero-grace run due during an outage
shorter than the tolerance dispatches instead of skipping. The alternative got
the far worse case wrong -- a multi-hour sleep replayed on wake.
2026-09-15 02:50:00 -07:00
Neil 231e805b1e fix(lint): enable anti-slop/no-shape-in-symbol-names (#20785)
Flip `anti-slop/no-shape-in-symbol-names` from "off" to "error" and clear
every violation under src, config, tests and mobile.

What the rule bans
------------------
The case-insensitive substring "shape" in any JS/TS identifier: variables,
functions, parameters, types, type parameters, class members, private names,
object-literal keys and JSX identifiers. The one exemption is a statically
accessed member read owned by another value (`zodObject.shape` is fine), so
third-party APIs stay readable without a suppression.

"Shape" names a value's structure rather than its domain role. `UserShape`,
`validateArgShape` and `errorShape` all tell you the symbol is "an object
with some fields" -- which is already what a type says -- while saying
nothing about what the value is for or who owns it. The rule forces the
name to carry the domain instead.

Violations fixed
----------------
689 violations across 109 files at baseline (verified by re-running the
audit against the pre-change tree with the rule set to "error").

Fix pattern
-----------
Rename for the domain role, not the structure:

  -type FieldShape = 'list' | 'map' | 'whole'
  -const FIELD_SHAPES = { ... } satisfies Record<keyof Observation, FieldShape>
  +type FieldEncoding = 'list' | 'map' | 'whole'
  +const FIELD_ENCODINGS = { ... } satisfies Record<keyof Observation, FieldEncoding>

  -function assertGitPushTargetShape(target: unknown): void
  +function assertValidGitPushTarget(target: unknown): void

  -function describeReadDirPathShape(p: string): ReadDirPathKind
  +function classifyReadDirPath(p: string): ReadDirPathKind

Predicates became statements about the value (`isDeltaShapedProviderFrameKind`
-> `isDeltaProviderFrameKind`, `isDeleteShapedDiscardEntry` ->
`discardDeletesEntryFile`, `isSkillsCliAgentKeyShaped` ->
`isUsableSkillsCliAgentKey`). Type aliases dropped the suffix where the
remaining name was already unambiguous (`GhGraphqlErrorShape` ->
`GhGraphqlError`).

No wire-visible name was renamed: no IPC or RPC channel, stream opcode,
request/response param, persisted field, or i18n key. The `--shape=symlink|copy`
CLI flag read by .github/workflows/skill-update-roundtrip.yml is unchanged --
only the local variable holding it was renamed.

Exemptions
----------
They are file-scoped entries in config/oxlint-anti-slop.json, not inline
`oxlint-disable` comments. An inline directive naming an anti-slop rule reads
back as an UNUSED directive under the root lint scan, which does not load this
plugin -- the changed-code quality gate counts that warning, so the comment form
cannot be used for a rule that lives only in this config.

* src/renderer/src/components/browser-pane/annotate/**:
  in the screenshot annotator a "shape" is the drawn geometry -- pen, arrow,
  rect, ellipse, highlight. That is a genuine domain noun, and it pervades
  every symbol in the module.
* repo-icon.tsx, repo-header-project-actions.tsx, mobile MobileRepoIcon.tsx:
  lucide exports the icon component as `Shapes`. The name is theirs, and the
  matching REPO_LUCIDE_ICONS key is the persisted icon name shared with the
  desktop picker -- renaming it would orphan saved repo icons.
* src/shared/onboarding-state-types.ts, src/shared/constants.ts:
  `shapedSidebar` is a persisted onboarding-checklist field and a telemetry
  enum member; renaming it would orphan saved state.
* src/shared/rpc-contract/rpc-send-params.ts: matching zod's own literal `shape`
  property is what selects the ZodObject branch of the conditional type.

No exemption was added merely to avoid a rename. Eight symbols initially
suppressed as "a cross-module refactor outside this change" were proven to have
zero non-TypeScript references repo-wide and renamed instead.

Zod's `ZodRawShape` needed no exemption at all: `Readonly<Record<string,
z.ZodType>>` is its definition, so repo-update-params.ts and
ui-update-value-tolerance-params.ts spell it out instead. Likewise
telemetry-event-classification.ts now reads `.shape` through an `in` narrowing,
which also retires two pre-existing type assertions; three more assertions the
rename had dragged onto changed lines (two `JSON.parse` sites, one node:sqlite
row read) became annotations and an explicit row mapping.

Verified
--------
* Audit reports zero violations; confirmed the rule genuinely fires by
  planting a probe violation.
* node config/scripts/run-typecheck-projects-in-parallel.mjs exits 0.
* Vitest over src/shared, src/main/github/project-view, the annotate module,
  the repo-icon components and the Chromium SameSite electron spec: all green.
* All 66 removed "shape" identifiers grepped repo-wide across every file type;
  none survive.
* node config/scripts/generate-rpc-params-catalog.mjs --check exits 0.
* node --check on every changed .mjs; oxfmt clean on all changed files.
* `pnpm run check:code-quality:changed` reports 0 findings.

Not machine-verified: the 3 mobile/ files (its Vitest run cannot resolve
`expo/tsconfig.base.json` in this worktree), and the WSL- and Playwright-gated
specs. All are rename- or comment-only hunks, read in full.
2026-09-15 02:00:27 -07:00
Neil bfdec26352 fix(lint): enable anti-slop/no-object-parameters (#20781)
The rule rejects the broad `object` type on any function input (declarations,
expressions, arrows, methods, call/construct signatures, function types), plus
local aliases and unions that resolve to `object`. `object` accepts every
non-primitive while exposing no properties, so it documents nothing and pushes
callers into assertions at the boundary.

Fixes all 185 violations across src, config, tests and mobile, and flips the
rule from "off" to "error" in config/oxlint-anti-slop.json.

Approach: replace each `object` input with the type its owner already has.
Most sites took an existing domain type or a type-only import (36 added);
40 new aliases name shapes that had none. Where a value is genuinely only
compared by reference, it gets a named identity token instead of a shape --
`Record<string, never>`, the built-in `WeakKey`, or a `unique symbol` brand,
matching the branding already used in src/shared. Same treatment for WeakMap
and Map key parameters. Two `as unknown as` casts became unnecessary once the
parameter carried a real type and were removed; no new casts were added.

Suppressions added: none. No `oxlint-disable` for this rule anywhere, and no
max-lines disable or per-file bump.

Three files sat exactly at their max-lines cap, so the added type imports were
made line-neutral rather than suppressed:
- src/main/ipc/browser.ts exports the existing guest-registration args type
  (renamed BrowserGuestArgs) so browser.test.ts reuses it on one line.
- pane-scroll.ts takes TerminalScrollIntentTarget through the existing
  pane-manager-types import via a type-only re-export.
- direct-rpc-client.ts drops the identity parameter entirely: the session
  check moved into the sendProbe callback that owns the token.

Verified: anti-slop config reports zero violations over src config tests
mobile; run-typecheck-projects-in-parallel exits 0; 144 affected test files
pass (1749 tests); oxlint and oxfmt clean on all changed files. Mobile has no
runnable test/typecheck target in this worktree (expo is not installed), so
its 6 files were typechecked against a standalone config and diffed against
the base branch -- error sets are byte-identical, including test files.
2026-09-15 01:59:58 -07:00
Neil e4a9d24e0c fix(automations): repair cron step expansion and day restriction (#20202)
The semantic half of the cron repair. Both defects change what an already-saved
schedule does, so they ship together and behind a decision.

#15723: parseCronField set end = start for a bare numeric field even with a
slash step, so 5/15 expanded to [5] and fired hourly instead of every fifteen
minutes. N/step is the open-ended N-max/step sequence now.

#15896: day restriction came from expanded set cardinality, so 1-31 read as
unrestricted and */2 as restricted. Restriction is lexical now: a day field
restricts iff no term of it ranges over a star, matching vixie cron and
robfig/cron rather than crontab(5)'s prose. Verified differentially against
robfig/cron v1.2.0 across 22 expressions, 424 days, zero divergences.

The two cannot ship apart: 0 9 1/1 * 1 matches 124 days under the old parser,
104 under #15723 alone, and 730 under both, because the old cardinality flags
react to the corrected expansion.

describeAutomationScheduleDrift reads a saved expression under both semantics
and reports the ones that moved, so neither direction is silent; the service
names them once at startup. No expression Orca's own presets generate drifts.

Fixes #15723
Fixes #15896
2026-09-15 01:28:23 -07:00
Neil 37394e9cb7 build(release): compile the Windows relay process-table addon (#20809)
* build(release): compile the Windows relay process-table addon

#16598 added build-windows-process-tree-relay-addon.mjs and the
ORCA_REQUIRE_RELAY_NATIVE_ADDONS gate, but wired both into
dev-channel-win-build.yml only. release-cut.yml was never touched, and
stageWindowsProcessTreeAddon merely logs when the addon is absent, so
every stable release has shipped Windows relays without
windows-process-tree.node.

Confirmed by extracting the installers: v1.4.191 (the first stable
carrying the feature), v1.4.198 and v1.4.203 all have no
windows-process-tree.node in relay/win32-x64 or relay/win32-arm64. Those
hosts have been taking the CIM fallback the whole time — 1247ms and a
powershell.exe per scan against 57ms native, on #16598's own ~1490-process
measurement host.

Mirror the dev-channel steps. Same windows-2022 image, so the MSVC ARM64
cross toolset the arm64 leg needs is already proven there, and the addon
build runs before the long packaging step so a missing component fails in
seconds with MSB8020 naming it.

* build(release): keep the Build app env rationale attached to its step

The new addon step landed between the ORCA_POSTHOG_WRITE_KEY / BUILD_IDENTITY
/ DIAGNOSTICS_TOKEN_URL comment block and the Build app step it documents,
orphaning it. Move the step above the block and record why it carries no
run_attempt guard: Build app is ungated, so a guarded addon step would let a
rerun reach the required-addon check with nothing staged.
2026-09-15 01:25:45 -07:00
Neil c0fb04c8d2 fix(relay): open the real null device when detaching Windows stdio (#20808)
* fix(relay): open the real null device when detaching Windows stdio

`openSync('NUL')` does not reach the null device on Windows. node's fs runs
the path through `toNamespacedPath`, which resolves it against cwd and
prefixes `\\?\` — and that prefix turns off DOS device-name mapping, so
CreateFileW creates a regular file named `NUL` in the relay's install dir
and pins fds 0/1 to it instead of to a discard sink.

Verified on a Windows 11 host: `fs.openSync('NUL', 'w')` + a 5-byte write
produced a 5-byte file named `NUL` in cwd. `\\.\NUL` is passed through
`toNamespacedPath` verbatim; the same write discards and a read answers
EOF, with no file created.

It also escaped into shipped artifacts. release-cut.yml runs the relay
watcher fault harness with cwd = out/relay/win32-x64, so every Windows
installer since v1.4.169 carries `resources/relay/win32-x64/NUL`, which
NSIS extracts as `_NUL`.

* test(relay): prove the `\\?\` rewrite on a drive-letter path

`toNamespacedPath('NUL')` off Windows only resolves against a POSIX cwd and
stops; with no drive letter it never reaches the branch that adds `\\?\`. So
the assertion held for the wrong reason and did not demonstrate the rewrite
the comment describes. Assert it on an absolute drive path, which takes the
same branch on every host.
2026-09-15 01:25:41 -07:00
Neil f107499e44 fix(lint): enable anti-slop/no-reflect-get (#20786)
`anti-slop/no-reflect-get` rejects every call to `Reflect.get`. The
reflective read bypasses ordinary property access and throws away the
type evidence the compiler would otherwise give you: the result is
`any`/`unknown` with no narrowing, so a typo in the key or a shape drift
in the source object is invisible until runtime. The rule's remedy is to
parse dynamic input into a named domain type (or narrow it with `in`)
and then read the field normally.

Baseline: 86 violations across 67 files. Now zero unsuppressed
violations under
`npx oxlint --config config/oxlint-anti-slop.json --ignore-pattern 'config/oxlint-plugins/anti-slop/**' src config tests mobile`.

Fix pattern
-----------
44 of the 86 were rewritten. The dominant shape was an `unknown` value
read through `Reflect.get` right after a `typeof === 'object'` guard;
those became `in`-narrowed property access, which TypeScript checks:

  - Reflect.get(value, 'agents')
  + 'agents' in value ? value.agents : null

Two further shapes:
- `Reflect.get(Object(x), 'k')` on a possibly-primitive envelope became a
  small named reader that boxes once and indexes a
  `Record<string, unknown>` (`settingsField` in
  mobile/src/transport/settings-read-operations.ts).
- Tests reaching into private state moved to TypeScript's checked
  bracket-index escape hatch (`runtime['layoutQueues']`), or to a
  documented read-only accessor on the owning class
  (`SearchSubprocessLineAccumulator.retainedCapacityBytes()`,
  `CodexSubagentExecutions.retentionSizes()`).

No type assertion was added anywhere: the diff contains zero net-new
`as` casts, `as any`, `as unknown as`, `@ts-ignore`, or
`@ts-expect-error`, so nothing was laundered into the sibling
assertion rules.

Suppressions
------------
42x `// oxlint-disable-next-line anti-slop/no-reflect-get` across 38
files. Every one is the default-forward branch of a `Proxy` `get` trap:

    get(target, property, receiver) {
      ...
      return Reflect.get(target, property, receiver)
    }

`Reflect.get(target, property, receiver)` is the only construct that
forwards with correct `receiver` semantics; `target[property]` invokes
an accessor with the wrong `this` and silently breaks getters that read
sibling state. There is no typed alternative, so these are suppressed
rather than rewritten.

3x `// oxlint-disable-next-line typescript-eslint/consistent-type-definitions
-- declaration merging requires interface` in
tests/e2e/github-url-smart-input-transition.spec.ts,
tests/e2e/linear-url-workspace-entry.spec.ts, and
tests/e2e/worktree-active-delete-scroll-position.spec.ts. Replacing
`Reflect.get(window, 'x')` with typed `window.x` requires a
`declare global { interface Window }` block, and `interface` is
mandatory for declaration merging. Matches the existing convention at
tests/e2e/helpers/runtime-types.ts:63.

1x `// eslint-disable-next-line no-var -- main-process gate handle for
this spec` in tests/e2e/project-group-creation-visibility.spec.ts, for
the same reason a `var` global is needed to type the handle. Matches
tests/e2e/agent-session-log-tail-stability.spec.ts:24.

Also updates two source-text anchors in mobile's rpc-recording mutation
harness (mobile/src/test-support/rpc-recording/operation-mutations.ts
and recording-runner.test.ts), which pin the exact text of the rewritten
line in settings-read-operations.ts and would otherwise fail with
"Mutant anchor matched 0 sites, expected 1".
2026-09-15 01:24:30 -07:00
Neil f7b2736d6d fix(worktree): block removal when the archive hook fails (#20153)
* fix(worktree): block removal when the archive hook fails

A repo's orca.yaml archive hook is the user's last chance to save work off a
checkout Orca is about to delete. A failed hook was logged as advisory and
stepped over, so the removal went ahead with nothing archived — and the caller
could still be told it succeeded.

The hook is now a blocking precondition, evaluated while the checkout, its Git
registration, its agents and Orca's ownership evidence are all still intact: it
sits ahead of the registration re-read, the lock/dirty preflights, stopPtys()
and removeWorktree in every orchestrator that runs it.

Failure is typed (worktree_archive_hook_failed) and carries the worktree path,
outcome, exit code where one was observed, and the hook's output. unverifiable
stays distinct from exited, so loss of contact is never read as a pass. The
waiver rides its own field at every layer and is never implied by --force, which
already carries the PTY-stop waiver; when used, the waived failure comes back on
result.archiveHookOverride rather than being swallowed.

worktree.archive-failure-blocking.v1 is advertised so an integration can tell
"accepts --run-hooks" from "safely propagates a failing hook" without risking the
data loss to find out. The runtime's SSH path cannot run a hook at all, so rather
than delete with the archive step silently skipped it refuses — waivable like
every other refusal here. #18563 retires that gate by making the path run the
hook for real.

Stacked on #20559, which makes a timed-out hook report honestly; without it a
hook that traps SIGTERM and exits 0 would defeat this gate.

Fixes #19334

* fix(worktree): close the skip-confirm dead end and the client/hook timeout gap

Four review findings on the gate.

A retry from the failure toast could fail for a DIFFERENT reason than the one
the user had just answered, and that second failure got a bare toast with no
buttons. With skipDeleteWorktreeConfirm set, the delete helpers pass no force, so
waiving a failed archive hook on a dirty checkout landed on the dirty preflight
and stopped there. Retry failures now re-enter the same failure toast, so every
retry stays as actionable as the first attempt. Third instance of this class.

The renderer gave worktree.rm a 60s budget while an archive hook may run for
120s. A hook that took 90s and succeeded timed the client out and reported
failure while the host went on to delete — telling the user their delete failed
and their checkout was gone. The budget is now derived from the hook's, and only
when a hook can run.

The SSH fail-open is logged rather than silent, and the capability's doc comment
scopes what it claims: a hook that RUNS and fails cannot delete the checkout; it
is not a promise the hook was found.

The SSH owner-resolution test now reads a real remote orca.yaml through a stubbed
provider and asserts the returned script is the remote one. It previously stopped
at the lookup key, which is the coverage that let this path break twice. It fails
against the row-only resolution.

* fix(worktree): name a signalled hook exit, and state why prunable cleanup skips the gate

Two things the rebase onto #20617 and #20576 surfaced, both found by rerunning
the real-repo harness rather than by reading the diff.

- #20617 added a registration-cleanup branch that returns before the archive
  gate. That ordering is correct — both of its arms describe a row with no
  checkout behind it, so there is nothing to archive and running the hook would
  fail on the missing cwd — but the gate's ordering invariant is documented, so
  the exception should be too.
- A signalled hook reported `Command failed with exit code null.`, which reads
  as a reporting glitch rather than the `unverifiable` verdict it is about to
  produce. It now says the command was terminated without reporting an exit
  code. Introduced by #20576; the withheld `exitCode` itself was always right.

Fixes #19334
2026-09-15 01:19:32 -07:00
Neil 37a5b278b3 test(package): reject an Electron install takeover by exact command (#20799)
* test(package): reject an Electron install takeover by exact command

CodeRabbit was right about #20787. Replacing the pinned postinstall string
with a /electron/i keyword check was wrong in both directions, verified:

  rebuild-native-deps.mjs && rebuild-native-deps.mjs   PASSED  (should fail)
  rebuild-native-deps.mjs && check-electron-version    FAILED  (should pass)

The owner's own path contains no "electron", so duplicating it slipped
through -- the one case the contract is named for. And a substring match
rejects any later step that merely mentions Electron, which is the same
over-tightness that broke every open PR in the first place, relocated.

Later steps are now checked against the exact owned command plus the known
Electron install commands. A second case pins the rejections themselves,
because reading the real postinstall cannot show a bad chain would be caught
-- that is how #20787 shipped with a guard that did not guard.

Split into its own file rather than adding a max-lines disable (AGENTS.md).

* test(package): match install commands as tokens and cover the rebuild:electron alias

Both review comments were right, verified by running them:

  && check-install-app-deps-version.mjs   rejected by substring match (should pass)
  && pnpm run rebuild:electron            slipped through (should fail)

package.json:101 aliases rebuild:electron to the owned script, so invoking it
is the same takeover. Matching is now token-based with the owned command still
checked as a phrase, and both cases are pinned.
2026-09-15 01:10:35 -07:00
Jinjing 6fe140ded8 Report clipboard and composer drop failures (#20795)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.

* fix(source-control): surface a failed notes copy instead of swallowing it

* Simplify diff comment notes copy error message

Replace parameterized translation template with a direct string. Add
explicit type annotations in tests to improve type safety.

* Sanitize clipboard write error messages for user display

- Only user-friendly messages for recognized errors
- Native failures logged but not exposed to UI
- Prevents information disclosure (CWE-209)
2026-09-15 00:57:23 -07:00
Jinjing 0569ca4cdc Improve microphone permission errors and drop failure reporting (#20801)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.

* fix(settings): say when the microphone is blocked and where to grant it

* Use generic stream for microphone permission requests

- Request generic audio stream instead of saved device to handle stale
  device IDs (unplugged microphones). This ensures the initial permission
  grant succeeds even if the previously saved device is no longer
  available.
- Refactor error handling to not require instanceof checks, supporting
  errors thrown as plain objects and improving robustness across browsers.
- Simplify tests with proper typing and add coverage for stale device and
  permission error edge cases.

* fix type check

* minor type fix
2026-09-15 00:55:28 -07:00
Neil 22ce8d69a1 fix(lint): enable anti-slop/no-module-mocking (#20783)
The rule rejects `vi.mock` / `vi.doMock` / `vi.unstable_mockModule` and the
`jest` equivalents, on the argument that a test which rewrites the module graph
asserts against a stand-in the production code never sees. It is already off for
`**/*.test.{ts,tsx}`, `**/*.spec.{ts,tsx}`, `tests/**` and `**/__mocks__/**` via
the existing override in config/oxlint-anti-slop.json; that override is
unchanged here. What the rule actually catches is module mocking that has drifted
out of a spec and into a first-party `.ts` support module, where nothing marks it
as test-only.

73 violations at baseline, all of them in test-support code. 9 were relocated
back into spec files the override already exempts; the remaining 64 sit in 10
files that are test-only but do not match the override globs, and carry a
file-level disable naming the rule and the reason.

Relocated:
- terminal-hydration-store-test-bootstrap.ts: the sonner / sync-runtime-graph /
  pty-transport `vi.mock` calls moved into the two specs that import it
  (terminals-hydration-canonical-rows, terminals-hydration-canonical-pty-overlap).
  Vitest hoists `vi.mock` inside a test file, so registration is strictly earlier
  than the previous module-eval-time call; the bootstrap keeps only the preload
  API proxy. Both importers were updated.
- ipc-events-ssh-authority-test-fixtures.ts: the 6 direct-ssh `vi.doMock` calls
  moved into useIpcEvents-agent-status-ssh-authority.test.ts as a local
  `stubDirectSshModules()` helper, which also de-duplicates the three copies the
  spec already had inline. The fixture now returns the store state and coordinator
  doubles it builds, typed via the exported DirectSshReconnectCoordinatorDouble.

Suppressed, with justification (each is `/* oxlint-disable
anti-slop/no-module-mocking -- ... */`, rule named, no blanket disable):
- config/scripts/headless-serve-shutdown-matrix.test.mjs (1) - a genuine Vitest
  spec that the override misses only because its globs say {ts,tsx}. The script
  under test is a top-level CLI module; the alternative is spawning real docker.
- src/main/codex-accounts/runtime-home-service-test-harness.ts (1) - stubs one
  probe predicate in ../pty/shell-startup-env, imported directly by several
  main-process readers; 17 specs share it.
- src/main/computer/desktop-script-provider-test-harness.ts (2) - stubs
  child_process/fs-promises for a provider that shells out; 8 specs share it.
- src/main/github/work-item-search-test-harness.ts (4) - one consumer lives in
  tests/e2e, where the relative mock ids resolve differently, so moving the calls
  into the specs would silently stop mocking there.
- src/renderer/src/components/automations/automations-page-test-harness.tsx (14)
  - the mount rig for 10 AutomationsPage specs.
- src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts
  (1) - stubs refreshWebRuntimeSessionTabsSnapshot, imported directly by several
  renderer runtime modules; 18 specs share it.
- src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts (7) -
  stubReactSyncEffect/stubAuxiliaryModules, shared by 11 specs.
- src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts (11) - stubs
  and hook invocation are one unit; 4 specs share it.
- src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts (13) - its
  only spec is at 799 of an 800 max-lines budget.
- src/renderer/src/hooks/ipc-events-test-harness.ts (10) - shared by 8 specs.

No violation was converted to real dependency injection, and no max-lines disable
was added.

Verified: the audit command exits 0 with no output (and reports errors on a
planted probe, so the rule is live); node config/scripts/run-typecheck-projects-in-parallel.mjs
exits 0; 354 spec files / 2506 tests covering every importer of every touched
file pass. No mobile/ file was touched.

The changed-code quality gate's root Oxlint scan runs without --config so it never
loads the anti-slop JS plugin, which made all 10 of those file-level suppressions
read as "Unused oxlint-disable directive". check-changed-code-quality.mjs now
exempts directives naming an anti-slop rule from that unused-directive warning,
the same carve-out isCastingDirectiveUnusedWarning already makes for the casting
suppressions the casting config enforces. Such a directive can never suppress a
root-config rule, so nothing the root scan would otherwise report is hidden;
audit:anti-slop remains the scan that enforces the rule.
2026-09-15 00:41:17 -07:00
Jinjing 775a932651 fix(git): distinguish binary absence from missing cwd on spawn ENOENT (#20798)
* fix(repos): preserve unknown Git availability

* fix(git): distinguish binary absence from missing cwd on spawn ENOENT

Node reports ENOENT for both a missing git binary and a missing working directory
during spawn. The fix checks specifically for spawn syscall, then verifies the cwd
exists to disambiguate. This prevents reporting "no Git" when the error is actually
a missing working directory. Centralizes probe logic in a reusable function; other
failures cause rejection so callers preserve the unknown status instead of collapsing
to false.
2026-09-15 00:22:15 -07:00
Neil 49e5fa597a refactor(lint): enable anti-slop/no-reflect-apply (#20782)
`anti-slop/no-reflect-apply` rejects `Reflect.apply(fn, thisArg, argsArray)`.
It defeats the call-signature checks TypeScript applies to an ordinary call:
the args array is checked as an array, not positionally against the callee's
parameters, so arity and type errors pass silently. Dynamic dispatch belongs
behind a named interface, not behind a reflective call.

Flipped the rule from "off" to "error" and cleared all 17 baseline violations
across `src config tests mobile` (16 sites; one file had two).

Fix pattern: `Reflect.apply(fn, recv, args)` becomes `fn.call(recv, ...args)`,
or a direct method call when the implicit receiver is already the right object.
The receiver is preserved at every site.

Where the callee is a captured built-in whose overloads split on an argument's
shape (`String.prototype.split`, `JSON.stringify`), a call-signature capture no
longer compiles once the args are passed positionally. Those three sites capture
the function through a method-shaped type
(`{ split(separator: unknown, limit?: number): string[] }['split']`), which keeps
the forwarding call checked rather than asserted.

Behaviour notes:
- `diff-section-layout.test.ts` drops a `limit === undefined ? [sep] : [sep, limit]`
  conditional. Equivalent: `String.prototype.split` maps an undefined limit to
  2^32-1, and the `Symbol.split` path forwards undefined either way.
- `workspace-space-compaction.test.ts` forwards `reduce`'s two arguments unchanged,
  so the `arguments.length >= 2` initial-value branch is unaffected.
- `agent-session-history-byte-accounting.test.ts` is the one site where the receiver
  is not literally preserved (`JSON` -> undefined). `JSON.stringify` never reads
  `this` per spec, and restoring `.call(JSON, ...)` would reintroduce the overload
  failure under strictBindCallApply.

No suppression comments added — the rule has zero `oxlint-disable` sites.

`Reflect.apply` still appears at electron.vite.config.ts:159, inside a template
literal of generated bootstrap source. That is string content, not lintable code.
2026-09-15 00:10:11 -07:00
Neil c9ae17fe3d fix(lint): enable anti-slop/no-unknown-type-aliases (#20784)
Flips anti-slop/no-unknown-type-aliases from "off" to "error" and fixes the
3 baseline violations.

The rule rejects a named type alias whose resolved type is `unknown` (directly,
through another alias, through parentheses, or as a member of a union). Such an
alias is strictly worse than writing `unknown`: it reads like a real domain type
at every use site while accepting anything, so the compiler stops helping and
readers are actively misled. `unknown` is fine, but it must stay visible at the
boundary that actually parses it.

Violations fixed (3 at baseline, 5 source files touched):

- src/main/runtime/workspace-session-failed-write-rollback.ts
  `type RollbackValue = unknown` -> a real recursive JSON-shaped union
  `RollbackSlot` (primitives | null | undefined | typeof MISSING |
  readonly RollbackSlot[] | RollbackRecord), with a named
  `type RollbackRecord = { readonly [key: string]: RollbackSlot }`.
  The record is a named alias rather than an inline index signature because
  inline violates typescript/consistent-indexed-object-style, `interface`
  violates consistent-type-definitions, and `Readonly<Record<..>>` trips
  TS2456 circular-reference. The named alias satisfies all three.

- src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts
  `type DirectSshReconnectTimer = unknown` -> `ReturnType<typeof setTimeout>`,
  the handle that actually flows. `DirectSshReconnectTargetState.timer` is
  widened to `DirectSshReconnectTimer | null` to match the state machine, which
  initializes to null and resets to null in the scheduled callback.

- src/renderer/src/hooks/direct-ssh-host-hydration.ts
  `type HostReadTimer = unknown` -> `ReturnType<typeof setTimeout>`.

Fix pattern throughout: replace the alias with the type that already flows
through the code, never with `any` and never with a relabelled `unknown`.
Because the timer aliases are now honest, two pre-existing
`as ReturnType<typeof setTimeout>` casts at the clearTimeout boundaries could be
deleted, a net win under the repo's type-assertion policy.

Suppressions added: none. No eslint-disable, oxlint-disable, `any`, or `as`
cast was introduced anywhere in this change.

The diff is type-annotation-only; no runtime statement changed.
2026-09-15 00:02:04 -07:00
Jinjing 3ec6193e0f fix(pty): preserve child-process inspection uncertainty (#20756)
* fix(pty): preserve unverifiable local child reads

* fix(pty): make child-process inspection synchronous

Separate foreground and child-process sampling. Sample child processes
synchronously after confirming foreground availability, returning
unverifiable verdicts when pty reads fail. Handle both transport loss
and local read failures uniformly in the completion coordinator.

* fix(pty): handle retired masters and pane instance swaps

Detect when node-pty retires the master fd (fd == -1) and return
unverifiable instead of misreading the spawn file as an idle shell.
Guard inspectProcess against PTY replacement mid-read to avoid pairing
old foreground with replacement's children.

* fix test

* fix tests
2026-09-14 23:53:05 -07:00
Neil 18d0afc918 test(package): let the postinstall contract allow unrelated chained steps (#20787) 2026-09-14 23:26:48 -07:00
github-actions[bot] cf19735a4d Update README downloads badge 2026-09-15 06:20:16 +00:00
Neil bbd808a63d fix(lint): keep root postinstall as the sole Electron binary install owner (#20788)
#20726 appended the anti-slop plugin sync to postinstall, which breaks the
contract asserted by package-electron-runtime-contract.test.mjs and is failing
on main.

The sync is not needed there: audit:anti-slop already runs it before linting, so
a cached install that skips postinstall still works.
2026-09-14 23:06:05 -07:00
Brennan Benson ab6b86dd5c fix(orchestration): require registered structured worker pane key (#20664) 2026-09-14 23:00:35 -07:00
Neil 4a5b0583b2 fix(runtime): keep listed handles when graph sync learns a PTY incarnation (#20779)
reconcilePtyIncarnationHandles compared a null retained incarnation against the learned one and staled the handle. Daemon-hosted PTYs are recorded from first output before the spawn commit reports an incarnation, so on Windows `orca terminal create` returned a handle that was stale by the next graph publish. Treat null-to-known as un-fenced like every other site; keep the known-to-different and preallocated-handle invalidations.
2026-09-14 22:43:38 -07:00
Neil b79206533e chore(lint): enable anti-slop no-reduce-accumulator-copy and no-widen-then-assert (#20780)
Both rules already report zero violations, so this only locks in the current
state as a ratchet. No source changes.
2026-09-14 21:58:56 -07:00
Neil 11180fa532 chore(lint): add anti-slop oxlint plugin (pinned, all rules off) (#20726)
* chore(lint): add anti-slop oxlint plugin (all rules off)

Vendors dmmulroy/anti-slop (MIT) plus no-call-only-assertions and
no-pass-through-type-alias from maharshi365/deslop (MIT). Every rule starts
"off"; each follow-up PR fixes one rule's violations and flips it to "error".

* fix(lint): actually exclude the vendored plugin from the anti-slop audit

oxlint does not honour ignorePatterns supplied via --config, so the
config/oxlint-plugins/anti-slop/** entry never matched and the vendored rule
source was being linted as first-party code (505 violations). Move the exclusion
to the --ignore-pattern CLI flag in audit:anti-slop, which does work, and drop
the entry that gave a false sense of coverage.

Keeping vendored source unlinted matters because anti-slop is updated by
three-way merge against the upstream snapshot; reformatting it locally would
conflict on every update.

* chore(lint): pin anti-slop instead of vendoring it; drop deslop

Replaces the ~5k vendored lines with a git-pinned devDependency:
  oxlint-plugin-anti-slop: github:dmmulroy/anti-slop#c44ef22

anti-slop ships raw .ts with no build step, and Node refuses to type-strip
anything under node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so
oxlint cannot load it from there -- which is why upstream says to vendor it. A
postinstall step copies the pinned package's source to .anti-slop-plugin/
(gitignored), which Node will type-strip because it sits outside node_modules.
Upgrading is now a SHA bump rather than a re-vendor and three-way merge.

Verified byte-identical rule output to the vendored copy across all 16 rules
that fire.

Drops maharshi365/deslop and its two rules (no-call-only-assertions,
no-pass-through-type-alias). It is not on npm either, so it would need a second
git pin and copy step, and it is a 5-star single-maintainer repo that is itself
a re-namespaced copy of anti-slop. One upstream is enough.

* ci(lint): run audit:anti-slop in PR CI

config/scripts/pr-workflow-lint-parity.test.mjs requires every step in
`pnpm lint` to have a matching step in .github/workflows/pr.yml; adding
audit:anti-slop to lint without the workflow step failed that ratchet.

Also makes audit:anti-slop sync the plugin itself before linting. The generated
.anti-slop-plugin/ directory is gitignored and otherwise only created by
postinstall, so a cached install that skips postinstall would leave oxlint
unable to load the plugin.
2026-09-14 21:42:37 -07:00
Neil ef39f32d4f test(native-chat): split the windowing test harness out of the suite (#20773)
#20719 grew NativeChatMessageList.windowing.test.tsx to 897 effective lines,
past the 800 ceiling for test files, so oxlint fails on main.

Moves the shared layout/ResizeObserver stubs into
native-chat-windowing-test-harness.tsx. No test was changed, split or dropped:
still 5 describes and 23 it() blocks, 29 assertions passing. The stubs' mutable
knobs become one exported `layout` object because an imported binding cannot be
reassigned across modules.

AGENTS.md forbids a max-lines disable, so extraction is the fix.
2026-09-14 21:35:48 -07:00
Jinjing 99062ed80b fix(worktrees): preserve unverifiable disk witness (#20713)
* fix(worktrees): preserve unverifiable disk witness

* fix(worktrees): follow gitdir/commondir markers in disk witness

The disk witness validates created worktrees by reading the repo's common directory from disk. Previously it only checked for a direct .git directory and returned a status object that conflated different failure modes.

Now it properly follows .gitdir and commondir pointer files to locate the true common directory, fixing detection on repos with linked git directories (worktrees, submodules) and WSL scenarios. Error handling is simplified: definitive absence returns undefined, other read failures throw with proper cause chains, eliminating the ambiguous "unverifiable" state that would mask real errors.

* fix: validate gitdir marker targets are directories

When a .git marker points to a missing or non-directory path, that's
unverifiable—not the same as an absent .git file (bare repo). Validate
accessibility before reading commondir to catch these errors clearly.
2026-09-14 21:03:51 -07:00
Brennan Benson 438603f9e7 feat(native-chat): add a message rail for jumping between your prompts (#20719)
* feat(native-chat): add a message rail for jumping between your prompts

A vertical rail down the right edge of the transcript, one bar per user
message, with the bar for the turn you are reading highlighted once
scrolling settles. Hovering the rail opens a panel that previews every
prompt and jumps to it on click.

Bars are capped at 20 and sampled evenly across the thread, always
keeping both ends and the active bar, so the rail stays readable at a
glance on a long conversation.

The active bar is resolved from virtualizer offsets rather than by
scanning rendered rows: the transcript is windowed, so an off-window row
has no element to measure. The row at the scroll fold resolves to its
owning prompt through turnKey, which is what keeps your own message lit
while you read a long reply instead of going dark.

Jumps reuse the existing reveal/pin path and scrollMessageToTop, which
releases the bottom pin. Scrolling through the virtualizer directly would
leave a reader snapped back down by the next streamed token.

Ticks cover loaded history only; older prompts gain a bar once "Load
earlier messages" pages them in.

* fix(native-chat): service a rail jump once and give its pin back

The rail borrowed the diff reveal's pin to reach a row the window had left
behind, but copied only its state shape, not its consumption. The request
was never cleared and the effect depended on `slots`, which is rebuilt on
every render, so three things went wrong at once:

- every later render re-scrolled to the jumped message, dragging a reader
  back there for the rest of the pane's life, and forcing the bottom pin
  off each time;
- the standing request outranked `revealedDiff` in the shared pin, so
  revealing a diff outside the window silently stopped mounting its row;
- the pinned row stayed mounted and measured indefinitely.

The request now carries a monotonic id, is serviced once, and is released
as soon as the scroll is issued, which hands the pin back.

The rail's scroll listener had the same churn: it listed `items` in its
deps, so a streaming turn tore the listener down and cancelled the pending
idle timer on every frame and the highlight never settled. It now
subscribes once and re-reads on a key built from the prompt ids.

Also: the hover trigger is a real button, because `asChild` discards the
primitive's focusable trigger and the panel is the only way to reach these
messages; the wheel forwarder honours line and page delta modes rather
than treating every delta as pixels; and the e2e panel assertion is exact,
since a loose bound passed at 20 rows against 20 ticks.

* fix(native-chat): make prompt rail accessible and reuse previews

* fix(native-chat): supersede prior navigation when selecting a prompt
2026-09-14 18:31:40 -07:00
Brennan Benson ff5b1a5a05 fix(native-chat): preserve detached transcript position during growth (#20710)
* fix(native-chat): stop the transcript following an end it measured short

The virtualizer compensates a row's measured size change by moving scrollTop
whenever it believes the view was already at the end. It decides that from the
spacer's own height minus a container-absolute offset, so the distance it
computes is short by everything in the document outside the spacer: the
transcript's top gutter, the "load earlier" block while older history is still
pageable, and the trailing chrome. A reader sitting ~100px above the bottom
therefore measured as "at the end", and every row that settled below them
dragged them down to it.

Measured in the windowing harness with a 92px gutter and 24px of trailing
chrome: a reader parked 96px above the end is pulled to the end on the first
growth frame, scrollTop 9261 to 9357.

The same option gates following an append, but that path measures the true
document distance, so it was never wrong, only redundant. The transcript
already decides whether to follow the end from the scroll container's real
geometry, and it re-pins once the growth is in the document rather than before
it, where the library's own write is clamped. Both library end behaviours are
retired by a threshold no finite distance can meet; the prepend anchoring that
shares the option is kept.

overflow-anchor:none is restated as structural: the engine's anchoring writes
never pass through the scrollToFn adapter that attributes this pane's own
scrolls, so they would arrive unmarked and read as the reader leaving.

* fix(native-chat): preserve visible rows on first measurement
2026-09-14 18:24:04 -07:00
github-actions[bot] 6f4e4bfa22 Update README downloads badge 2026-09-15 01:00:51 +00:00
Neil b61a2347b9 feat(design-system): gate renderer UI with @shadcn/lint (#20731)
* feat(design-system): gate renderer UI with @shadcn/lint

Wires shadcn-ui/lint's Oxlint plugin into the two places this repo already
ratchets: the changed-lines PR gate for rules the renderer can't satisfy
today, and `pnpm lint` for the one that is already at zero.

- config/oxlint-design-system.json: no-restyle (layout allowed),
  no-raw-colors, require-static-classes -- scoped to src/renderer/**/*.tsx,
  run over added lines only. Measured at 10 findings across the last 60
  commits (771 changed files), so it holds the line without a migration.
- config/oxlint-dead-classes.json: no-unknown-classes repo-wide, with the
  renderer's plain-CSS hook namespaces allow-listed. Now at zero.
- no-inline-styles and no-arbitrary-values stay off; STYLEGUIDE says why.

Fixes the three live bugs the linter found:

- `--editor-surface` never reached `@theme inline`, so `bg-editor-surface`
  generated no CSS -- 12 editor/artifact/notebook panes fell through to the
  page background instead of #1e1e1e in dark mode.
- `scrollbar-none` is not a Tailwind utility and was declared nowhere, so
  the remote file browser breadcrumbs showed the scrollbar they meant to
  hide. Declared as a real `@utility`.
- Notebook markdown cells used `markdown-preview-body`, which no stylesheet
  defines; the styled class is `markdown-body`. They rendered unstyled.

* ci: run the dead-class gate in PR CI

`pnpm lint` gained check:dead-classes, and pr-workflow-lint-parity requires
every `pnpm lint` step to have a matching step in pr.yml.

* fix(notebook): keep markdown theme selectors working
2026-09-14 17:52:21 -07:00
Brennan Benson db09a7bd50 fix(native-chat): let a reader park just above the latest message (#20709)
* fix(native-chat): let a reader park just above the latest message

A reader who scrolled up by less than the bottom threshold was still
classified as being at the end, so follow stayed armed and the next chunk
of stream carried them back down. One constant was answering two
different questions: how close to the end still counts as pinned, and
whether a reader's own scroll meant to stay there.

The first wants slack, because a streaming last message jitters in height
by tens of pixels. The second wants almost none, because it is a
statement of intent. Give it its own, far stricter band, and move the
choice of band into the decision rather than leaving it to the call site,
which is where the two got conflated.

Re-arming follow now requires the reader to be within 4px of the end:
enough for fractional-pixel and zoom rounding, well inside one line of
prose. The pin and the jump-to-latest affordance keep their 48px band.

* fix(native-chat): make transcript intent own end following
2026-09-14 17:47:19 -07:00
Neil 2b34255d96 fix(ci): stop defining pilot mutant tests inside a conditional (#20755)
`vitest/no-conditional-tests` fires on the `if (mutation) { it(...) }` inside
the pilot loop, and `audit:code-quality:native` runs oxlint with
`--deny-warnings`, so main's "Enforce focused code-quality plugins" step exits
1 and blocks every open PR.

Pair each pilot with its pinned mutant and reference state before the loops, so
every iteration defines exactly one test unconditionally. Same 14 tests, same
names: 11 mutant-kill tests and the 3 reference tests that `skipIf` still gates
on RPC_FOUNDATION_REFERENCE_ROOT.
2026-09-14 17:47:11 -07:00
Neil 20794ee785 ci: keep the baseline build off the compatibility matrix lanes (#20733)
The compatibility gate started the pinned 2.25.5 source build inside the same
step that runs the three measured lanes, so `make -j$(nproc)` competed with two
container lanes whose wall clock is container starts, not Git. A boundary case
that costs ~1.5s stretched past Vitest's 30s timeout and failed the job.

Build the binary in its own step before the matrix, and pull both images before
any lane starts so a lazy pull cannot stall whichever test its sibling is timing.
2026-09-14 17:32:33 -07:00
Jinjing ffc331212c Fix PTY child process verdict to preserve unverifiable state (#20729)
* fix(pty): preserve unverifiable local child reads

* fix(pty): make child-process inspection synchronous

Separate foreground and child-process sampling. Sample child processes
synchronously after confirming foreground availability, returning
unverifiable verdicts when pty reads fail. Handle both transport loss
and local read failures uniformly in the completion coordinator.
2026-09-14 17:13:25 -07:00
Jinwoo Hong 6a11a0b8e6 test(mobile): pin each RPC golden to the recorder inputs that can reach it, not the whole directory (#20662)
* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's

`recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR
that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its
merge with main conflicted on that one line in 153 files; every future domain PR would collide
with every other in flight the same way.

Split the directory at a real seam instead of a filename convention: `adapters/` holds one module
per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers
the engine only. A new `adapterSha256` covers the source of the module that mounts each operation
a golden's scenarios drive, read off the same `mounts` calls that build the table the recording
runs against, so the pin cannot name a file the runner did not use.

Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the
goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file
inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file,
and an adapter importing a sibling each fail.

The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which
leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new
header field; the goldens re-record in the next commit.

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

* test(mobile): re-record the RPC goldens under the split recorder/adapter digest

Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers
`adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and
recording ran against the same pinned product tree.

    git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
      | grep -vE '^(\+\+\+|---)' \
      | grep -vE '^[+-]  "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l
    0

The seven `adapterSha256` values partition the 153 goldens by the module each was recorded
through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory,
9 tasks, 8 workspace settings.

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

* refactor(mobile): stop pinning goldens to recorder inputs no recording can read

The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the
per-family mutant registry beside it, and the probe-hole witness. None can change a recording --
the loader consults a mutant only when a mutant test asks for one, and no suite but the two
recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have
and charged every domain a full re-record for it.

`mutants/` now holds the table, the registry, the reference states, the mutant suites and the
probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can
reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by
name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts`
checks exactly that, and fails if an engine file names the directory or anything outside imports
from it.

`recorderSha256` also pins only the suites in `recording-drivers.ts`, which
`scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or
writes one to a scratch directory, is no longer provenance for a recorded file.

`OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold
the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own
exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden.

Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to
`root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed
anything. Each call now spells the root differently.

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

* test(mobile): re-record the RPC goldens under the mutant and driver exclusions

Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153
because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that
module now carries its own exposure declaration.

    git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
      | grep -vE '^(\+\+\+|---)' \
      | grep -vE '^[+-]  "(recorderSha256|adapterSha256)":' | wc -l
    0

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

* fix(mobile): restore the preferences actions the merge resolution dropped

#20568 added `resume` and `trust` actions to the `settings.task-preferences`
adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already
moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the
`pilot-mount-adapters.ts` conflict in favour of the registry merge silently
discarded them and `tw-task-preferences-resume-write` failed to record at all
("Missing or completed request: ui.set#1").

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

* test(mobile): re-record the RPC goldens at main's tip after the merge

All 208 goldens, header-only. `baseline` moves from 50e752fc66 to main's tip
c6a7216984, `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
c6a7216984 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
2026-09-14 20:11:21 -04:00
Jinwoo Hong b07c4032ea Log bounded PostgreSQL acquisition and execution failure diagnostics (#20749) 2026-09-14 20:07:24 -04:00
Jinjing 4bcdc67369 Distinguish pane load failures from empty states (#20735)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(settings): tell a failed load apart from a genuinely empty pane

* refactor: consolidate import types and simplify failure handling

- Move filesystem import types to shared for renderer use
- Add compactIpcErrorMessage for single-line error display
- Consolidate entry failure toasts to single global slot
- Simplify account tracking and discard retry logic

* fix type

* fix: clear stale state when pane loads fail

Credential reads, account fetches, and skill scans can fail, leaving stale
data on screen. This change clears previous state when a load fails,
distinguishing load failures from genuinely empty results, and prevents
stale controls from appearing after failed re-checks.

Use readIpcErrorMessage for consistent error handling and track runtime
targets to invalidate results from old targets.

* fix(settings): show credential action when bitbucket status read fails

When the credential-read operation fails, allow users to retry by showing
"Add or replace credentials" button. Initialize the credentials dialog with
the current (confirmed) connection state instead of stale data from a failed
read, preventing outdated information from pre-populating the form.
2026-09-14 16:44:30 -07:00
6cb5643241 fix(deps): migrate Tiptap security updates with Markdown compatibility guards (#19376)
* chore(deps): evaluate coordinated Tiptap security migration

* fix(editor): adapt link ranking and initialization for Tiptap 3.31

* fix(editor): preserve literal Markdown through Tiptap serialization

* test(editor): cover literal saves in local folder and paired workspaces

* test(editor): reselect folder after closing its final tab

* perf(editor): avoid repeated inline source-marker lookahead scans

* refactor(editor): inline redundant HTML match wrapper

* test(chat): await Tiptap React skill-pill rendering

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-14 16:36:06 -07:00
Jinjing b8554f1c59 fix(composer): clarify failed attachment drops (#20704)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.
2026-09-14 15:22:05 -07:00
Neil 767b7c14f1 fix(ai-vault): expand nested OMP session history (#20663)
Expand saved OMP descendants lazily while preserving exact child targets for Resume and View Log. Retain expanded branches across virtual scrolling and reject late responses/cycles. Includes the independently reviewed child-workspace correction from #20629.

61 combined target/map/nesting tests and actual OMP child/grandchild storage/CLI smoke pass. Earlier hidden Electron proof covers eight generations and narrow sidebar layout. Folder-only unresolved child targets remain disabled. No live delegation or full terminal-launch proof claimed.

Addresses #12885 Scope 2.
2026-09-14 15:17:36 -07:00
Neil 742a7ad842 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.
2026-09-14 15:05:36 -07:00
Brennan BensonandMerge Sim f55b7ba680 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>
2026-09-14 14:59:03 -07:00
Neil dd85e5fc81 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.
2026-09-14 14:54:53 -07:00
Neil 41e42beab4 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
2026-09-14 14:52:45 -07:00
Neil bac96b212e 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
2026-09-14 14:52:35 -07:00