Commit Graph
88 Commits
Author SHA1 Message Date
Neil 85f8d6b5f5 test: retire long-tail cases whose assertion is decided by the test itself (#24132)
Resumes the backlog sweep at a chunk size that actually gets read. Six auditors, 84 files
each, and all six read their full scope case-by-case against production — the first wave
where every chunk closed with no gap. 33 case declarations removed across 22 files, 1 test
file deleted, 826 lines gone. No production code touched.

This wave exists because a conclusion of mine was wrong. I had recorded that yield collapsed
~36x and that deletion was no longer the high-value work. I was dividing cases removed by
files IN SCOPE while the fraction auditors actually READ fell from 100% to about 4%, because
I kept handing them 300-800 files. Recomputed against files read, yield has been flat at 4-7
per 100 with no downward trend. This wave came in at 8.2.

The most instructive removal looked like the most valuable test in scope.
`orchestration-worker-release-reap-fixed.func.test.ts` cites a production bug by two
identifiers, describes orphaned PTYs accumulating until `TasksMax=4096` aborts processes on
EAGAIN, and advertises itself as the functional tier wiring the real orchestration RPC
surface, the real `OrchestrationDb` and the real release modules. Deleting it leaves no
reference to that bug anywhere in `src`.

It still had to go: its fake runtime performed the fence it asserted —

    if (pty.incarnationId !== inc) { return null }
    handleTable.set('term_reminted', { ptyId, epoch: rendererGraphEpoch })

— so the case checking that a reused ptyId with a mismatched incarnation does not resolve was
checking a decision its own spy made twenty lines earlier. The real fence is owned by
`orca-runtime-terminal-handle-incarnation.test.ts:257`, and the other two cases replay
`orchestration-worker-release-incarnation-fallback.test.ts` (which uses a plain
`mockReturnValue` rather than reimplementing the remint) and `worker/worker-release.test.ts:23`.
"Integration test" and "wires real modules" describe the scaffolding, not the asserted step.

Other removals: a self-comparison disguised by an alias, where
`export const getIssueOwnerRepo = getOwnerRepo` makes a case asserting the two "agree" into
`f(x) === f(x)`; four cases whose `vi.mock` of `resolveIssueSource` made both the preference
value and the topology inert; five verdict-precedence cases owned by a verdict-agnostic block;
three call-shape probes on one-line store pass-throughs whose real contracts are driven by
behavioural neighbours; and a `export type _Ref = [...]` declaration whose own comment admits
it exists only to preserve test-only module-surface references.

Kept after checking production rather than shape. An auditor found two near-identical
ten-reconnect loops and kept both: one uses a test-local live-lease filter, the other the
shipped `sshRemotePtyLeaseAllowsReattach` predicate, and the file's own comment explains the
duality is deliberate "so the two cannot drift". Another kept a paths-alignment case that
looks like a validator tested against its own list, because adding a generated file without
registering its path does fail it — and `shellReadyWrappersExist` uses that registered list to
decide whether a partial tree needs regeneration.

Production duplication is now confirmed four times over, and it is why mirrored tests exist:
`createUpdateWorktreeLineage`/`createAssignWorktreeParent` differ by one `console.error`
string; `terminal-path-tap.ts` and `document/path-tap.ts` carry hand-maintained copies of
`matchFilePathAtColumn` under a docblock reading "keep the two in sync". In those cases both
test sides are load-bearing and the duplication belongs on a refactor list.

`mobile/tests-typecheck-baseline.txt` loses one entry. Trimming
`relay-host-signed-out-verdict.test.ts` made it typecheck clean, so the ratchet required
pruning its grandfathered entry — the file graduates from exempt to enforced. Baseline is now
124 entries, down from 125.

Verified: 690 test files / 7,560 cases pass across the touched desktop areas; the modified
mobile files pass (162 cases); `check-tests-typecheck-ratchet.mjs` OK (898 files in program,
124 grandfathered); `check-reliability-gates.mjs` 140 gates; the deleted file is absent from
the gate manifest, `cloud/package.json` and the mobile baseline; nothing under
`mobile/src/test-support/rpc-recording/` or `mobile/rpc-foundation/goldens/` touched.
2026-09-30 04:58:31 -07:00
Brennan Benson ca7c14db08 fix(mobile): start + menu, quick command and diff-note agents through agent.launch (#22954)
* fix(mobile): start + menu, quick command and diff-note agents through agent.launch

The session screen's + menu, agent quick commands and diff notes' New agent
session now ask the host to start the agent with agent.launchReplay, so the
host picks chat or terminal from the desktop's default and delivers any
prompt. Hosts without the launch capabilities keep today's paths.

The phone's pending tab choice is one value (a tab, a terminal by handle, or a
launched surface) instead of two refs, and a launched chat is found by its
session id in the next snapshot rather than a predicted tab id. A launched
surface waits a bounded number of snapshots for its tab.

* test(mobile): add the + menu and diff-note launch scenarios to the recording corpus

* test(mobile): repin bridged-parity tallies for the four launch goldens; drop test casts

The corpus grows from 790 to 794 goldens; all four new ones replay identically.

* fix(mobile): show a refused agent launch as a toast beside open tabs

The inline create error renders only in an empty session, so a host refusal
(for example a disabled agent) from the + menu in a session with tabs showed
nothing. Always toast the failure: the caller's own copy when it gave one,
otherwise the host's reason.

* test(mobile): type the launch reply helper with the shared launch outcome types

* fix(mobile): record a launched agent's tab as this device's pick on the host

A launch carries no navigation, so the phone selected the new tab only
locally while the host kept this device on the tab it had before. Leaving
the session and coming back, or a reconnect that reset the screen, reopened
that old tab. The "+" terminal path this replaced asked the host to select
the tab for the caller.

When a launched surface's tab lands in a snapshot, activate it for the
caller exactly as a tap does. The resolver now names the landed tab in
place of the unused `missed` flag. Route parity re-pinned for the new
activation body, identity payload and strings.

* fix(mobile): land on a launched agent's tab without a 500 ms wait or a blank pane

The host publishes a launched tab before it replies, so the tab list the
phone already holds usually has it by the time the reply arrives. The
launch paths still waited for a refetch 500 ms later, leaving the phone on
the old tab for that long after every launch. Read the tab list at once.

On hosts without agent.launch, the chat path also unsubscribed the open
terminal and cleared its handle before the chat's tab landed, while the old
terminal tab stayed selected: a blank pane until the next tab list. Leave the
open tab live until the chat lands, as the launch path does; applying that
tab list tears the old terminal down.

Route parity re-pinned for the two bodies.

* fix(mobile): keep a tab the user picked while a prompted launch was still replying

A quick command or review-notes launch now waits for the host to deliver the prompt, which can take up to a minute. The launched tab shows up in the tab row well before that, so a user who tapped another tab meanwhile was pulled back onto the launched one when the reply arrived, and that pick was recorded on the host.

The launch now remembers which tab the phone was on when it started and only takes focus if the phone is still there when the reply lands. Any move made in between, by a tap or by the computer navigating this phone, wins. Session route parity re-pinned for the handleCreateTerminal body only.

* fix(mobile): name a launched agent's tab before asking, and land on it when it is listed

A "+" menu, quick-command or review-notes launch now reserves its tab before it asks the host: a fresh pane key (tab and leaf UUIDs) and, for an agent the host may start as a chat, a session id. Both are minted once per launch and sent unchanged on every replay, since the host's replay fingerprint covers them.

The phone arms its pending selection with that reservation before sending, so it lands on the terminal (matched by pane halves) or chat (matched by session id) as soon as the tab is listed. For an agent whose prompt is pasted after start, that is long before the reply, which waits for delivery. Landing also frees the "+" lock; the lock holds the create's id, so an older launch's reply cannot free a newer one's. The reply now only adds its own handle or session id (an older host ignores the reservation), starts the fallback countdown, and reports prompt delivery.

A tab the user picks mid-launch replaces the pending selection, so the launch-start tab check is gone. A reservation the host refuses as already taken reads "Couldn't start the agent. Try again." on the first send, and as unconfirmed after a replay. The mobile UUID fallback now yields a v4 UUID, because a pane key's leaf must be one. The host launch path moved to new-tab-agent-host-launch.ts; session route parity re-pinned for that move and the landing's lock release.

* test(mobile): expect the launch reservation in the four launch scenarios

The four launch scenarios now expect the pane key and session id the phone sends (the scripted ids come first, so the operation id moves from ...001 to ...004).

* fix(mobile): don't say an agent may not have started while the user is looking at it

When a launch's reply was lost after its tab had already landed, the phone said "Couldn't confirm the agent started", although the listed tab proves it did. Now a listed tab narrows the doubt to the prompt or notes ("The agent started, but couldn't confirm the notes were sent."), the notes stay unsent, and a bare launch says nothing. Only the nested-function parity pin moves, for handleCreateTerminal passing the tab list to the launch.

* test(mobile): read the launch's sent reservation through the host's params schema

The anti-slop audit rejects Reflect.get; parsing with AgentLaunchReplay also
asserts the host accepts the params the phone sent.

* test(mobile): check the launch reservation against the host without importing its schema

Mobile code may import the params contract only as types. The phone's tests
now read the sent reservation by narrowing, a chat reservation is checked
through the real host dispatcher, and the older-host drop is pinned host-side.

* fix(mobile): don't send the same review notes to a second new agent

The "+" lock is now freed when the launched tab lands, but review notes are
only cleared when the launch's reply confirms delivery, which for a prompted
launch can take up to a minute. In that window "Send review notes to AI" still
offered the same notes, and choosing a new agent session started a second
agent with them.

The notes a new agent session is being started with are now held from the tap
until that launch settles: the Send button no longer counts them, the sheet no
longer offers them, and a stale tap on the old sheet starts nothing. Notes the
host did not deliver become sendable again once the reply arrives.

* test(mobile): record the + menu and diff-note launch goldens

4 added (+ as a terminal, + as a chat, notes delivered, notes not delivered). 10 existing create-terminal goldens move only because the recorded state now shows one pending selection instead of two refs; their requests are unchanged.
2026-09-30 00:42:52 -07:00
Brennan Benson 75040eba5a test: open, seed and read the agent-session record store through one test harness (#23986)
* test: open, seed and read the agent-session record store through one harness

Tests that open the durable agent-session record store, seed it, or read
back what it persisted now go through agent-session-record-store-test-harness.ts
instead of calling AgentSessionRecordStore.open or touching agent-sessions.json
themselves. A later change that moves the store into the chat database then
changes the harness instead of every test. No production code changes.

Tests whose subject is the JSON file itself (its .bak recovery, salvage,
schema versions, permissions, and what older builds read back) keep reading
and writing the file directly; the storage move rewrites or deletes them.

* test: address the record-store harness by the host's state directory

The harness took the store's own folder, so each caller picked one
(join(root, 'store'), or 'agent-sessions' where a test read the store the
runtime owns). A later change that moves the store into the state
directory's journal database could not tell those apart, and would have
had to edit every caller again.

Every harness function now takes the state directory, the one the test's
journal database and recovery capsule already live in, and keeps the
store in the same subfolder the runtime uses. Callers pass that directory;
store-only tests pass their temp directory unchanged. Format tests that
share a directory with harness calls take the file path from
testAgentSessionStoreFilePath.

The folder name moves from a private constant in the runtime to
AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness
shares it without importing the runtime. Its value and every path built
from it are unchanged.
2026-09-29 23:38:43 -07:00
Neil 45c63a66e9 test: delete the source-grep tests an earlier detector's regex missed (#23976)
A rebuilt detector found 195 source-grep candidates where the original found 111.
The gap was one over-specific regex: the first scanner required a literal `.ts`
path inside `readFileSync(...)`, so every test that built its path from variables
(`join(dirname, '..', 'foo.tsx')`) was invisible to it. Roughly 84 files of a
pattern an earlier wave reported as cleared had in fact survived.

Deleted whole, every case asserting on production source text:
- `app-startup-routing.test.ts` (27 cases) — exact import statements
  (`"import('../components/UpdateCard').then"`), relative-path spelling, and
  `indexOf` source ordering. A file move or a `lazy()` refactor breaks it.
- `pull-request-page-host-boundary.test.ts` (13) — `toContain` on whole argument
  expressions concatenated across 20+ component files.
- `SmartWorkspaceNameField-source-boundaries.test.ts` (7) — placeholder copy, a
  Tailwind class string, and `not.toContain` on an already-deleted symbol.
- `github-project-repo-list-load.test.ts` (9) — `indexOf` statement ordering
  inside `loadTasks`.
- `github-enterprise-slug-routing-boundary.test.ts` (4) —
  `toContain('host: githubProjectHost(parsed?.slug.host)')`.
- `web-viewport-shell.test.ts` (3) — a regex demanding exact CSS selector-list
  ordering and whitespace.
- `agent-catalog-links.test.ts` (1) — restates two `homepageUrl` literals straight
  out of `agent-catalog.ts` with nothing in between.

Trimmed, keeping only what nothing else can reach:
- `desktop-startup-ordering.test.ts` 549 -> 66 lines, retaining the three cases
  named as `assertionRefs` by the `ssh-filesystem.stream-inactivity-lifecycle` and
  `agent-browser.owner-boundary-cleanup` gates; 15 source-order greps went.
- `ResourceUsageStatusSegment.session-polling.test.ts` keeps its census that no
  `setInterval` exists and `listSessions()` is called exactly once — an added poll
  multiplies a global daemon scan and no behavioral test sees it. The
  `indexOf('if (!open)')` ordering pair and four `not.toContain` lines went.
- `agent-skill-installed-command-callers.test.ts` 231 -> 86, keeping the
  `readdirSync` census that discovers every `<AgentSkillSetupPanel` caller and
  asserts set equality against the allowlist, so a new panel host cannot silently
  show a default Update action.

Also in this wave, from the renderer lib/runtime sweep: 22 cases whose routing
signal the production path never reads — verified by mutation, stripping
`connectionId`, the WSL preference and the UNC path from four of them left all 29
tests passing — plus braille-spinner rows collapsed onto one regex range, copied
`WELL_KNOWN_LABELS` rows, and a whole `resolveAiVaultResumeStartupShell` describe
whose four darwin/linux fixtures all return before the login shell is read.

`config/reliability-gates.jsonc` drops the two `app-startup-routing.test.ts`
references; the manifest still validates for 140 gates.
2026-09-29 19:55:50 -07:00
Neil 6194a7a1b6 test: drop private-internal and boundary-census tests with behavioral owners (#23941)
Fourth audit wave, cut short by a session restart, so this lands the verified
subset rather than the full batch.

Removes private-predicate cases whose behavior is already covered through the
module's real entry point, and de-exports the seams they reached for. Also drops
three whole files whose every case was a duplicate or a call-shape grep.

The source-grep vein is close to exhausted. One auditor reviewed 15 remaining
flagged files and deleted nothing: what is left is mostly legitimate
architectural ratchets that no type checker and no behavioral test can reach —
AST fences banning `as`/`any` in an RPC operation region, discovered-vs-listed
set equality over subscription sites, count ceilings on unchecked reply readers,
and assertions on generated WebView bundles (no CDN URL, no `</script`
tokenizer escape, parses at the Chrome 74 floor). Those stay.
2026-09-29 16:35:49 -07:00
Neil 6e1b7e7fa3 test: remove junk tests that assert source text instead of behavior (#23815)
Deletes 101 test files and trims 112 more, all matching documented junk
patterns: exact source/import/string greps, copied inventories and export
lists, duplicate invocations of a contract another test already owns,
typeof-shape checks TypeScript already enforces, and self-comparisons.

The largest group read a production `.ts` file and asserted on its text —
for example a TaskPage test that required the source to contain
`selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null`.
Any behavior-preserving rename broke it; no behavior change ever did.

Production-side follow-through: exports that only these tests imported are
de-exported or deleted, stale comments pointing at removed censuses are
dropped, and the reliability-gate registry, `cloud/package.json` test lists,
and orphaned source-reading helpers are updated so nothing references a
deleted file.

Two files kept their real coverage and lost only the census scaffolding:
`agent-status-producer-census.test.ts` now drives all five producers end to
end instead of grepping the source tree, and `config-toml-trust-stale-writes`
replaces an export-list parity check.
2026-09-29 01:21:53 -07:00
Neil ccdb324b63 Add CodeBuddy as a built-in coding agent (#23740)
* feat(agents): integrate CodeBuddy launch, status and session history

* docs: record CodeBuddy lifecycle verification

* fix(codebuddy): backfill scoped history and negotiate remote resume

* test(cli): include CodeBuddy in known search agents
2026-09-28 18:11:25 -07:00
Brennan Benson 29c7d5d983 fix(mobile): start AI-button agents through agent.launch, never a bare shell (#22762)
* fix(mobile): start AI-button agents through agent.launch, never a bare shell

"Fix checks with AI", "Resolve conflicts with AI", commit-failure recovery and
diff review's "New Agent Session" created a terminal with no agent and typed the
multi-line prompt into the shell, so each line ran as a shell command.

They now call agent.launchReplay into the existing workspace with the prompt;
the host picks chat or terminal from the user's default and delivers the prompt.
Hosts without the launch capabilities get the buttons disabled with update copy.

The agent comes from the desktop's own resolution (moved to src/shared). The
replay loop and capability read are shared with the workspace-create launch.

* test(mobile): repin bridged-parity tallies for the AI-button launch goldens

The corpus goes from 787 to 790 goldens: five shell-path goldens are removed and
eight agent.launch ones added; one lands in identical and two in
result-absent-settlement.

* test(mobile): re-record goldens for AI-button launches through agent.launch

Repinned baseline to 514ab7f868 and re-recorded all goldens. Against the branch
point: 781 header-only (baseline on all; adapterSha256 on the 48 goldens whose
adapter module changed; scenarioSha256 on 3), one body moved
(pr-triage-launch: createTerminal + terminal.send becomes agent.launchReplay),
eight added (the new launch outcomes and their reply matrices) and five deleted
(the shell-path scenarios and their matrices).

* fix(mobile): show review notes' agent launch progress and failures, once

"New Agent Session" left the sheet open with no progress for the whole launch
(up to a minute while a terminal agent readies), so a second tap started a
second agent, and a launch that never started or could not be confirmed
rejected an unobserved promise, showing nothing. The sheet now closes on tap,
the review screen says "Starting an agent...", one launch runs at a time, and
every outcome lands in the review screen's status line.

Marking notes sent now reads the screen state when the launch settles, so a
note written during the wait is not dropped by the whole-list save.

* test(mobile): re-record goldens for review notes' launch outcome on the review screen

Repins the corpus to 6f3018576d. One golden body moves:
review-create-agent-refused now fulfils with "Workspace not found" in the
review screen's status line and the sheet closed, where it previously
rejected an unobserved promise and left the status line empty. The other
789 goldens move only their baseline header.

* test(agent-status): drop the retired PR-triage terminal send from the identity inventory

The phone's AI buttons no longer create a terminal and send the prompt into it
(`createTerminalAndSendPrompt` is gone); the host's agent launch delivers it.
There is no terminal action consumer left in that file to pin.

* fix(runtime): publish saved source-control launch recipes to paired clients

settings.get is an allowlist and omitted sourceControlAi, so the phone never
saw an agent saved globally for "Fix checks", "Resolve conflicts" or commit
recovery and always fell back to the default agent. The host now publishes
the launch actions' recipes (agent, prompt template, agent args), normalized
so legacy saved defaults are already migrated. A new optional reply field:
older clients ignore it, and a client talking to an older host sees none and
keeps using the default agent.

* fix(mobile): ask to update Orca only when the host answered without agent launch

An unread or failed status read settles with no capabilities, which the AI
buttons read as an old host and showed "Update Orca on your computer". The
update copy now needs a status the host actually returned; an unread one keeps
the buttons disabled without blaming the desktop's version.

* fix(mobile): send an AI button's saved agent arguments with its launch

The desktop's direct launches for "Fix checks", "Resolve conflicts" and commit
recovery pass the action's saved agent arguments to agent.launch; the phone
honoured the saved agent but dropped its arguments. It now sends them the same
way: absent when none are saved, so the host keeps the user's configured
defaults. A host that predates the field ignores it.

* test(mobile): repin the RPC recording corpus after the launch recipe and availability fixes

Repins to fe85e0346f. All 790 goldens move only their baseline header: no
scenario saves agent arguments or reads an unreadable status, so no recorded
behaviour changes.

* fix(mobile): say the host status is unreadable instead of nothing when it is

With the update copy now reserved for a host that answered without agent
launch, an unread status left the AI buttons disabled with no explanation.
They now say "Could not read this host's status. Go back and reopen it.", the
words the mobile web shell already uses for the same failure; leaving the host
re-reads its status.

* fix(mobile): wrap an AI button's prompt in the action's saved template

The desktop renders every source-control launch's prompt through the action's
saved template (buildSourceControlRecoveryAgentCommandInput); the phone sent
its built-in prompt as is. Now that the host publishes the recipes, the phone
renders through the same shared function, refuses an empty result as the
desktop does, and offers the rendered text when it could not be delivered.
Review notes have no recipe and are unchanged.

* test(mobile): re-record goldens for the templated AI-button prompt

Repins to 7fd1555d20. One golden body moves: pr-triage-prompt-not-delivered
now carries the prompt as sent (rendered through the action's template) on its
prompt-not-sent result, which is what Copy prompt offers. The other 789
goldens move only their baseline header.

* fix(mobile): re-read a host status that failed while the connection stayed up

A status.get that timed out or was cut over settled the host's gates closed
and was never asked again until the connection state changed, so the phone's
AI buttons stayed disabled behind "Could not read this host's status" on a
link that was working. The gate still settles closed at once, so a failed
read never holds the host screen, but it now re-asks in the background with
the same backoff the runtime capability probe uses, and opens once a status
lands. A reply this app cannot decode is not re-asked.

* test(mobile): repin the RPC recording corpus after the host status re-read

The status gate change moves no recorded behavior: every golden's body is
unchanged and only its baseline header moves to the new pin.

* fix(mobile): show a launch's host warning as a note, not an error

A launch that went ahead can carry a host warning (a structured chat ignores saved agent
arguments, including the '' a template-only save writes). The AI buttons rendered it in the red
error line beside a success haptic. The notice now carries it separately as secondary text, and
review notes keep saying they were sent. Commit recovery also takes the synchronous in-flight lock
the PR triage buttons use, so two taps before a re-render start one agent.

* chore(mobile): record the host status re-read timer for React Doctor

The status re-read arms one retry timer from inside its read and clears it in the effect's
cleanup. React Doctor reports that self-rescheduling shape even in its minimal form, which failed
both changed-lines gates. Suppressed the same way as the session startup timers.

* fix(mobile): say review notes are waiting for the desktop instead of doing nothing

With no live connection, New Agent Session threw from a handler whose promise the sheet drops, so
the tap did nothing visible while the button stayed enabled (proven capabilities survive a drop).
It now closes the sheet and shows "Waiting for desktop..." as the other AI buttons do.

* fix(mobile): stop sending an AI button's saved agent arguments

Whether saved arguments apply depends on the route and shell the host settles after the request
(a chat ignores them and warns; malformed ones fail after admission), and the desktop sends them
only when they apply. The phone cannot know that, so it now leaves them out and the agent's default
arguments apply, as before this series. The saved agent and prompt template still apply.

* fix(mobile): mark review notes sent through the latest save

The sent marks after an agent launch went through the save callback captured at tap time, whose
rollback restores the screen from that moment, so a failed save could drop notes written during
the launch. It now uses the latest render's save, as it already did for the screen state.

* refactor(mobile): own the host status re-read outside the effect

The re-read loop lived inside the effect body, so React Doctor could not see its cleanup and
needed an inline suppression plus a config allowlist entry. The loop is now a plain function that
returns its stop handle, and the effect returns that handle, the same shape every caller of the
runtime capability probe uses. Both suppressions are removed; behaviour is unchanged.

* test(mobile): record the host descriptor from a background status re-read

Pins that the status read records the host descriptor when a re-read succeeds after a failed first
read, not only on the first answer.

* fix(mobile): show a PR AI launch notice only under the button that launched it

Fix checks and Resolve conflicts shared one error, warning and undelivered prompt, so a Fix checks
launch whose prompt was not sent also offered "Copy prompt" under Resolve conflicts, copying the
fix-checks prompt. Notices are now kept per button. The host availability notice stays under each
disabled button, since it explains why that button cannot be tapped.

* fix(mobile): say the host status is being retried instead of asking to reopen it

The host status gate now re-reads a failed status in the background, so "Go back and reopen it"
asked the user for a step that is no longer needed. The review sheet hint uses the same words.
The mobile web shell keeps its own copy.

* refactor(mobile): run the host status gate on the shared status probe

The gate had its own copy of the status probe's retry loop (same delays, same cutover and backoff
split, same stop on an undecodable status). The probe now takes an optional callback for each
failed attempt, which the gate uses to settle closed on the first failure, and the duplicate loop
and its now-unused reader are removed. Existing probe callers are unchanged.

* test(mobile): repin the RPC recording corpus after merging main

Re-records every golden against the merge commit and drops the three goldens whose
scenarios this branch removed, which the merge had restored from main.

* test(mobile): re-record the RPC goldens on the merge with main

Conflicted goldens were seeded from main and re-recorded against the merged
tree; every value either side recorded survives except main's terminal.send in
the PR triage launch, which this branch removes. Drops three goldens main still
had for scenarios this branch deleted.

* feat(mobile): confirm an AI button's agent started, naming the workspace

Fix checks, Resolve conflicts and commit recovery now show "Agent started in
<workspace>" under the button once the host started the agent with its prompt,
so a tap is no longer silent. The workspace label comes from the Source Control
panel and falls back to the branch.

* test(mobile): repin the recording baseline to the success-confirmation commit (header-only)

* fix(mobile): name the workspace in the diff review's AI-button confirmation

The diff review screen mounted the PR sidebar without a workspace label, so
"Agent started in ..." under Fix checks and Resolve conflicts named the branch
while the screen header named the workspace. The sidebar now requires the label
so no screen can drop it, and the diff review passes the one its header shows.

* test(mobile): re-record the RPC goldens on the merge with main

Repins baseline to the merge commit, the last commit to touch a fenced
path. Against this branch before the merge, only header fields move:
baseline on every golden, and adapterSha256 on the 14 review-action goldens
whose adapter main now drives through the review sheet state. No recorded
body changed.

* test(mobile): re-record the RPC goldens on the merge with main

Repins baseline to the merge commit, the last commit to touch a fenced
path. Against this branch before the merge only the baseline header moves,
on every golden; no recorded body changed.

* test(mobile): give the send-sheet stacking test the review controller's host status inputs

The merge with main brought in #22951's stacking test, which builds the review
controller without the host capability and status inputs this branch made
required, so the mobile test typecheck ratchet failed.

* test(mobile): re-record the RPC goldens on the merge with main

Repins baseline to the merge commit 03995ae29d, the last commit to touch a
fenced path. Against this branch before the merge only headers move: baseline
on every golden, and adapterSha256 on the 14 goldens recorded through the
terminal adapter main changed in #23080. No recorded body changed, and the
merged corpus differs from main exactly as this branch did before.
2026-09-28 14:28:09 -07:00
Jinwoo Hong 0034ede120 fix(mobile): left-align every line of the desktop host card (#23673)
* fix(mobile): left-align every line of the desktop host card

StatusDot carried its own marginRight on top of each row's spacing, so the
host card's status text sat 14 px in and the worktree line was hand-indented
to match. Move the dot spacing to the row gap in every consumer and drop the
worktree-line indent.

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

* test(mobile): re-pin tasks style parity hash for the title-row gap

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-28 15:31:40 -04:00
8b410b4893 feat: add first-class Qoder CLI support (#23581)
feat: add first-class Qoder CLI support

Integrate Qoder launch, identity, canonical hook status, trust and resume.
Verify with captured Qoder 1.1.64 transcripts and hidden Electron sidebar checks.

Builds on and cross-reviews #7502, #8611, #9655, #12910, #13311 and #15291.

Co-authored-by: dalveytech-vincent <vincent@dalveytech.com>
Co-authored-by: Eridanus117 <45489268+Eridanus117@users.noreply.github.com>
Co-authored-by: xingqingzzp-gif <xingqingzzp-gif@users.noreply.github.com>
Co-authored-by: jyang2004 <jyang2004@users.noreply.github.com>
Co-authored-by: yunqian <yunqian@alibaba-inc.com>
Co-authored-by: huzhening.hzn <huzhening.hzn@alibaba-inc.com>
2026-09-28 02:59:50 -07:00
400e4e7957 feat(agents): add Freebuff launch and sidebar status support (#23567)
Add Freebuff launch support and execution-host status reporting for the sidebar, including running, question, blocked, and settled states. Validate against captured CLI transcripts and real rendered sidebar evidence.

Cross-referenced community implementations #17065, #20839, and the Freebuff portion of #18790. Preserve their agent/catalog/mobile/documentation coverage and add canonical status publication and regression tests.

Co-authored-by: Harkaran Brar <18134082+harkaranbrar7@users.noreply.github.com>
Co-authored-by: Prarambha369 <98906077+Prarambha369@users.noreply.github.com>
Co-authored-by: Lesley Murfin <260182349+LesleyMurfin@users.noreply.github.com>
2026-09-28 02:32:41 -07:00
Neil 45f3512a33 feat(agents): add first-class DeepSeek Harness (dsh) support (#22468)
* feat(agents): add first-class DeepSeek Harness (dsh) support

Register DSH as a supervised Orca agent: catalog entry and detection for its
dsh-tui profile, status/question hooks through DeepSeek's own Claude-Code hook
bridge, composer-ready prompt delivery, session resume, headless Source Control
AI, and title identity that no longer collides with Gemini's.

* fix(dsh): reach Orca through DSH's credential scrub and stop reading its title as Gemini

DSH runs command hooks through its own shell executor, which drops every env var whose
name contains KEY, TOKEN, SECRET or PASSWORD — taking ORCA_PANE_KEY and
ORCA_AGENT_LAUNCH_TOKEN with it, so every hook exited without posting. Mirror both onto
scrub-safe aliases at spawn and restore them at the top of the DSH hook script.

Its title collided too: DSH rests on the same glyph Gemini works on, so a resting DSH
pane was relabelled Gemini CLI and reported working forever. Defer both the Gemini
classifier and the title status detector on DSH's whale, in the base module both copies
of that classifier read.

* test(mobile): repin the session-route closure for the DSH agent icon

* fix(dsh): address review — never splice user rows, cover remote panes, keep the diff off argv

- findManagedDshPatchRegion paired an orphan start marker with a later block's end, so a
  truncated write made install/remove delete the user's own rows. Pair each end with the
  nearest preceding start; regression test fails without the fix.
- The relay PTY env builder never applied the scrub-safe aliases, so remote DSH status
  silently never appeared even with the remote hook installed.
- Source Control AI sent the whole diff on argv; send it over stdin with DSH's '-' marker.
- dsh-tui/dst already chose the interactive profile, so a workspace folder named 'web' or
  'plugin' no longer marks a live agent pane non-interactive.
- Isolate USERPROFILE as well as HOME so a Windows run cannot edit the real home.
- Drop the duplicate README badge and revert an incidental doc reformat.

* refactor(dsh): share the managed-hooks reader and tighten the new modules

Reuse before reimplementing: readManagedDshHookEvents was a near-verbatim copy of Muse's,
with byte-identical private helpers. Both now call one readManagedHookEventsFromJson.

Also: one readTextOrAbsent instead of two spellings of the same read (dropping an
existsSync TOCTOU), one status() builder instead of four inline literals, rmSync(force)
instead of exists-then-unlink, and a redundant empty-string guard before JSON.parse.
The patch-file transforms lose their index juggling for a predicate plus a filter.

* fix(dsh): refuse a flow-style patch file, keep its mode, and stop the relay inheriting a pane

- applyManagedDshPatch matched only an exact `[]`, so `[] # keep empty` or a non-empty
  flow sequence got a block entry appended after it — invalid YAML that would leave DSH
  unable to load the user's own patch layer either. It now strips the token from an empty
  sequence (keeping a trailing comment) and returns null for a non-empty one; install
  reports that and changes nothing.
- The patch rewrite dropped an owner-only file to the umask default (CWE-732); pass
  preserveMode.
- The relay PTY env never dropped inherited pane identity the way the local and daemon
  builders do, so a spawn that specified none could inherit the relay's own and every
  agent's hook would report against that pane.

* fix(dsh): keep the flow-style refusal in every status read, and scope the mode test to POSIX

A refused patch file carries no managed region, so getStatus() fell through to a bare
not_installed with detail null — the actionable 'rewrite it as a block sequence' message
only ever reached the one-shot install() return. Export the predicate and check it first,
behind one shared message constant.

The owner-only mode assertion cannot hold on Windows, where chmod only toggles the
read-only attribute and mode & 0o777 reads 0o666 for any writable file.

* docs(readme): restore the DeepSeek Harness badge lost in the rebase

* test(mobile): repin the session-route closure to the measured 4221

Measured, not derived: 4220 without the DSH icon entry, 4221 with it. Two of the three
modules above main's 4218 pin are not this change's — they arrived with the mobile work
after #22570 and were never repinned; the changelog records that split explicitly.

* fix(dsh): settle tui-idle on the agent's own hook, so supervised workers see it ready

Reported by a tester on the adhoc build: `terminal wait --for tui-idle` ran to its 90s
timeout against an already-ready DSH composer, so a supervised worker never sees the agent
as ready.

Every existing tier reads the title, and DSH deliberately carries no title status: its rest
prefix is Gemini's working glyph, so the detector reports none. A fresh first-party `done`
is better evidence than any title anyway — it is the agent's own account of its own turn,
and normalizeDshEvent drops subagent events, so it is the lead's. Scoped to DSH: for agents
whose hooks report child turns, a mid-turn `done` is the #6011 class this file prevents.

* test(daemon): record the DSH transcript's true-colour I2 divergences

Adding the dsh-tui capture to __fixtures__ enrolled it in the serialize replay sweep, where
it reports 10 I2 divergences and failed the unlisted-transcript default of 0.

Every one is the same shape — visible-grid row=0, a 24-bit background the round trip does
not restore to default — which is DSH's whale intro painting whole rows of true colour.
Verified as an upstream limitation rather than a regression by replaying against the
previous build (build-serialize-addon-at-ref.mjs --ref origin/main): I1 and I3 both hold.

* fix(dsh): return the new tui-idle verdict from the first-party done lane

Main refactored isTuiIdleSatisfied into evaluateTuiIdle, which returns a verdict rather
than a boolean. The DSH lane still returned `true`; it is tier-1 positive evidence, so it
returns READY_STRONG like the title/body lane above it. Re-verified the regression test
still fails without the lane.

* test(relay): pin the scrub-safe pane-identity aliases on the relay spawn path

The relay builds a remote pane's env itself, so the alias mirroring there had no
test: removing the call left every suite green while remote DSH status silently
vanished. Both cases fail without it.

* docs(dsh): point the hook service at the integration reference

The reference doc had no inbound link from anywhere in the repo.
2026-09-27 22:44:18 -07:00
d06b43e634 fix(tasks): read a malformed saved Linear team selection as sticky-all instead of crashing the page (#22279)
* fix(tasks): read a malformed saved Linear team selection as sticky-all instead of crashing the page

A persisted defaultLinearTeamSelection that is not a string array (a string
reached 1.4.207, report 0a2b6e7f) threw '(t ?? []).filter is not a function'
inside a commit-phase effect and tripped the page.tasks error boundary. The
value is now normalized where the page reads it and where a host projects it
to paired clients; anything but a string array means sticky-all.

* fix(mobile): read a malformed saved Linear team selection as sticky-all

A host that predates the desktop fix projects its raw store value, so the
mobile Linear list must tolerate the same string shape. Also trims the
desktop helper's comments to the why.

* fix(tasks): validate projected Linear team IDs and refresh parity contract

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-25 21:01:32 -07:00
Neil 90801e2deb feat(agents): add first-class ZCode harness (#22464)
* feat(agents): add first-class ZCode harness

Add ZCode (Z.ai's `zcode` CLI) as a supervised Orca agent: managed lifecycle
hooks on local, SSH and Windows hosts; status, question and approval reporting;
synthetic status titles; session resume; orchestration worker launch options;
and desktop + mobile agent-picker registration.

Written against the newly open-sourced `zai-org/ZCode` (agent CLI 0.16.9), not
against a remembered screen:

- ZCode's hook runner writes a Claude-compatible stdin alias set, so it routes
  through the existing Claude-compatible vendor path while keeping its own
  identity in the sidebar.
- `PermissionRequest` fires only once the approval card is on screen and racing
  the user's answer, so it is proof the pane is blocked, not an auto-approval.
- ZCode's clarification tool is literally `AskUserQuestion` with Claude's
  questions/options shape, so Orca's question card renders it unchanged.
- ZCode's `hooks.enabled` defaults to false, which is why configured hooks were
  reported as never firing; the installer sets it.
- ZCode renames its own process to `zcode-cli`, so the expected foreground
  process cannot be the launch command or dispatch refuses the pane.
- ZCode emits no OSC title in any state and repaints its ASCII banner forever,
  so readiness comes from Orca's synthetic hook title and launch drafts wait on
  the composer box rather than on a quiet render window.

Three files crossed their max-lines limit, so each is split along a real seam:
command-line entrypoint parsing out of agent process recognition, skill
classification out of skill root discovery, and registry coverage out of the
remote hook installer tests.

Refs #10564

* fix(zcode): drop the session-option catalog and pin the orchestration contract

ZCode's CLI exposes no `--model` flag at all, and the session-option launch path
refuses to apply any option until a model id is chosen. A catalog therefore could
not deliver `--mode` per worker, and would have accepted `--model` only to drop
it silently. Take opencode's position instead: no catalog, so `worker-start
--model` is refused with a clear message and ZCode launches with the model from
its own config. `--mode` stays reachable through agent args, which is also how
the yolo default is applied.

Add a contract test covering the parts that make ZCode a usable worker:
dispatchable foreground process, stdin prompt delivery, the prompt staying out
of the launch command, and the composer-gated draft paste.

* refactor(zcode): reuse shared helpers and cut the harness down

No behaviour change; every ZCode test still passes.

- Use installer-utils' own `hookDefinitionHasManagedCommand` instead of
  re-walking a hook definition by hand, which also drops a local string reader.
- Share one `readZCodeEventMap` instead of keeping the same narrowing in both
  hook-settings and hook-config-json.
- Collapse five identical error returns into one `zcodeHookError` builder, and
  return early from the status branches instead of assigning through `let`.
- Split the event-to-status decision out of `normalizeZCodeEvent` into a pure
  `readZCodeTurn`, so the normalizer reads as decide-then-build and stops
  computing the tool name for events that never look at it.
- Take a script file name in `readManagedZCodeHookEvents` like its siblings,
  which removes a `Parameters<typeof …>` indirection at the call site.
- Drop the unused `ZCodeHookEvent` export and inline a single-use path helper.
- Correct a stale comment: ZCode's loader is a strict `JSON.parse`, so the
  in-place edit preserves key order and indentation, not comments.

* fix(zcode): address review — keep unmanaged event keys, correct comment, de-dupe README

- `removeZCodeManagedHooks` deleted any event key whose list ended up empty, so an
  unrelated `"Notification": []` the user wrote was removed as collateral whenever a
  managed hook elsewhere made the write happen. Only touch an event Orca actually
  owned something in; covered by a new regression test.
- The `isNewTurnEvent` comment claimed UserPromptSubmit was ZCode's only turn
  boundary while the expression below it also returned true for SessionStart. Say
  what the code does: SessionStart lands the idle boundary, UserPromptSubmit is the
  turn boundary (the Codex/Claude shape).
- ZCode appeared twice in the README's single agent-badge block; keep the
  local-icon entry the link checker validates and drop the favicon duplicate.

* docs(zcode): call out that the desktop bundle's CLI cannot open a session

From live testing on #22464: pointing `zcode` at the desktop app's bundled
`glm/zcode.cjs` installs Orca's hooks fine but then fails with
`Cannot find package '@zcode/tui'`, so the pane never opens a session. The
symptom reads as a broken harness when the CLI simply has no TUI. Say which
build to use and how to check before reporting a problem.

Reported-by: JWu527
2026-09-25 02:17:51 -07:00
Jinjing b419b3183e test: remove redundant mobile and GitLab checks (#22748) 2026-09-24 20:45:15 -07:00
Brennan Benson 7a4f080086 revert: #18790 (orchestration incarnation reap fallback and bundled Freebuff agent) (#22601)
This reverts commit 0677271709.

#18790 was merged as one squash commit that carried two unrelated changes:
a process-incarnation fallback for reaping leaked orchestration worker
terminals, and an unannounced "Freebuff" third-party agent (catalog entry,
icon, locale strings, README rows). The Freebuff agent was never meant to
ship, so the whole PR is reverted; the reap fix should be re-submitted on
its own.

Until that re-land, a worker whose durable terminal handle goes stale is
again reported missing on release/stop instead of being re-found through
its process incarnation, so its terminal can leak on Remote Server.

The mobile session page closure pin moves 4218 -> 4219: the revert drops
the freebuff icon #22119 pinned (-1), and #22452 had already added two
src/shared modules without re-pinning (+2).
2026-09-23 21:39:28 -07:00
Brennan Benson 069dc8a1d8 feat(agent-launch): let a caller reserve the chat session, and start terminal launches with the session picks (#22523)
* feat(agent-launch): let a caller reserve the chat session and carry session picks to a terminal launch

* fix(agent-launch): keep a caller-minted session id named for its agent, and mint the fallback the same way

* test(mobile): model the older host from the launch fields, not the refined schema

* docs(agent-launch): describe the reserved session id as conversation identity, not placement

The caller mints the session id so it knows which conversation it
started; tab placement is not keyed on it. Also puts the terminal
surface's doc comment back on createTerminalSurface.

* docs(agent-launch): say a terminal launch reads the session picks on the wire contract

The `sessionOptions` field doc still said a terminal launch ignores them, which this branch changed.

* fix(agent-launch): check a reserved session id's token after the agent name, not the whole id

A hyphenated agent name failed the one-token check, so any session id for such an agent was
refused at the wire, while every other agent without a chat has its id ignored on the terminal.
2026-09-23 16:45:28 -07:00
NeilandAdrien De oliveira ebed0964a2 feat(agents): add first-class Muse Code harness (#22216)
* feat(agents): add first-class Muse Code harness

Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage.

Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com>

Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com>

Co-authored-by: John Cusack <johncusackccm@gmail.com>

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>

* test(agents): cover Muse remote hook registration

* test(agents): cover Muse hook and source-control contracts

* test(agents): exclude Muse hook metadata from script mode check

* test(agents): keep Muse skill picker coverage stable

* test(ai-vault): include Muse in every-agent fixture

* test(mobile): repin Muse agent icon closure

* fix(muse): detect questions and approvals from structured Muse signals

Muse 1.3 fires no hook for request_user_input, so a pending question left
the pane "working". Its internal reminder subagents also post hooks with
their own session ids (even after Stop), which surfaced "tool failed" rows
and flipped finished panes back to working.

- Read pending questions from Muse's session log
  (user_input_prompt_requested/settled) via the existing transcript poll,
  now generalized from Codex subagents to Muse on main and relay.
- Drop child-session hooks (SubagentStart ids, or turn_id === session_id).
- Treat Notification permission_prompt as the approval wait; PermissionRequest
  also fires for auto-approved calls, so it only caches the approval card.
- Ignore Notification copy as the prompt; poll replays are not new prompts
  or turn boundaries.
- Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook.

* perf(muse): parse only question events from the session log

Most Muse session-log lines are large model/tool records. Filter raw lines
by the user_input_prompt_ marker before JSON.parse via an optional
readJsonlCursor line filter.

* fix(muse): unwrap batched log records and scope questions to the live turn

Review follow-ups: question events inside retained_frame batches were
skipped, and a question left open by a crash or interrupt stayed pending
for the pane's life. Share the history scanner's retained_frame unwrapper,
and only report a pending question whose run_id matches the hook turn_id.

* refactor(muse): drop type assertion in retained_frame unwrap

* fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy

---------

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>
2026-09-22 19:13:11 -07:00
0677271709 fix(orchestration): reap leaked worker terminals via process-incarnation fallback — stops an unbounded PTY/process leak on Remote Server (OOM / cgroup PID exhaustion) (#18790)
* fix(orchestration): remint live handle from process incarnation on worker release

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

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

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

Addresses two open CodeRabbit review comments on PR #18790.

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

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

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

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

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

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

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

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

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

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

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

## ELI5

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

## What Changed

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

## Why

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

## Linked Issue

N/A

## Visual Proof

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

## Testing

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

## AI Disclosure

Assisted by AI coding tooling.

## Checklist

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

---------

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

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

* test: document worker fixture type boundaries

* test: simplify worker fixture typing

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: svc-orca[bot] <313947298+svc-orca[bot]@users.noreply.github.com>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-21 17:23:33 -07:00
Jinwoo Hong 335d111265 fix(mobile): name the Back control in every page-served screen (OTA phase C, C2.8) (#21715)
* fix(mobile): name the Back control in every page-served screen

The C2.7 device proof could only reach the tasks Back by tapping the
native control's coordinates: inside the shell there is no native chrome
behind it, so a bare Pressable is absent from the accessibility tree and
a screen reader has nothing to announce. The agent-history panel's Back
carries a label but no role; the tasks one carries neither.

Both gain `accessibilityRole="button"`, and the tasks one the app's own
wording, `accessibilityLabel="Back"` — the spelling its thirteen
siblings use, including the agent-history panel this screen sits beside.
The host screen's Back already carried both and did not move.

The new census is a rule rather than a count: it finds every Back
affordance under the screen tree each page route serves — a Pressable
rendering a ChevronLeft that either carries the back-button style or
calls back — and requires a button role and a Back-shaped label on each.
Its coverage assertion reads MOBILE_WEB_PAGE_ROUTES, so a route
registered without a screen entry fails here, the way the flag census
fails for a switched route. Red first on both offenders by name.

Two tasks parity hashes move, and only the two a JSX prop must move:
`semantics` 3,272 -> 3,274 for the widened host signature and the two
new runtime strings, and the render-token stream 35,195 -> 35,203 for
the eight tokens the two attributes are. Hooks, statements,
declarations and styles hold, which is the evidence that nothing
executable changed. Recorded in the test's comment.

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

* fix(mobile): judge a Back control by what it does, and name the files pair

Round 1 found the census discriminating on the ChevronLeft glyph, which
separates nothing: dismisses sit in the same header slot with the same
back-button style, so the rule claimed a dismiss and told it to be
called Back, while the explorer's Close escaped only by drawing an X.

The predicate is now the behaviour. A Back control is a Pressable whose
press handler reaches a back call — a `back()`, `goBack()` or `onBack()`
call, a handler named for one, or a local handler whose declaration
makes one — or whose label already says Back. A control matching
neither is outside the rule whatever it renders. The reviewer's
mutation, a dismiss with the back-button style wrapping a ChevronLeft
behind `onDismiss`, is claimed by the old predicate and passed over by
this one, which is the whole of the difference.

A spread now reads as unknown rather than absent, so a Pressable that
hides these props behind one is a Back control that fails both rules
instead of a control the scan never sees. No Pressable in the five
trees spreads today, so the branch is inert and load-bearing.

C3.1 registered the files explorer and preview, so the coverage
assertion went red on its merge, as designed. Both screens join
PAGE_SERVED_SCREENS and both Back controls gain
`accessibilityRole="button"`; they already carried `Back to session`
and `Back to files`. The tasks Back gains the `hitSlop={8}` its four
siblings carry, so its touch target is no longer the glyph alone.

Nine mutations: the dismiss stays out; a dropped role on each of the
five controls and a dropped label on the tasks one go red by name; a
spread fails both rules; a dropped table entry fails coverage; and
dropping the host screen's label — its only detector, since
`actions.leaveHost` is opaque — empties that tree and fails the
presence assertion rather than passing in silence.

Two parity hashes move for the one added prop and no count does:
`semantics` holds at 3,274 lines with one changed, the host signature
gaining `hitSlop`, and the render-token stream gains that attribute's
four tokens, 35,203 -> 35,207. Recorded in the test's comment.

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

* fix(mobile): assert a Back per page route, not per screen directory

Round 2 found the presence assertion reading directories while two
routes share `src/files`, so the explorer's Back answered for the
preview's as well: rewriting the preview's control to `onPress=
{handleClose}` with `accessibilityLabel="Close"` left that route with
no accessible Back and the suite green. Reproduced exactly, then made
red by this change, which names the route and the module it failed in.

Each PAGE_SERVED_SCREENS entry now names the module that renders its
route's Back, and presence is asserted per entry. The rules still read
whole trees, so a Back added beside a screen is ruled as well as the
screen's own; the tree is derived from the module's directory rather
than declared twice. The failure prints the offending list instead of
comparing two values a reader cannot tell apart.

The doc comment claimed the preview was carried by the label half of
the predicate. It is not: `requestBack` is named for what it does, so
the preview is found behaviourally like the explorer, the tasks screen
and the agent-history panel. Dropping the label from each of those
three leaves it detected and failing the naming rule, which is the
evidence. Only the host screen depends on the label half, because
`actions.leaveHost` is a member access this rule does not follow.

The admitted residual gap stays admitted and the predicate is not
widened for it: a second Back control in a screen that already has one,
with an opaque handler and a label that does not say Back, is still
outside what this rule sees.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 16:36:21 -04:00
Jinwoo Hong b6e8b1a7b2 feat(mobile): serve the tasks screen from the page, with its seams (OTA phase C, C2.1 + C2.5) (#21694)
* fix(mobile): encode the host id in the tasks workspace-creation href (OTA phase C, C2.1)

`use-mobile-tasks-workspace-create-actions.tsx` built
`/h/${hostId}/session/...` with the host id interpolated raw — the C1.2 class.
A host id carrying `/`, `#`, `?` or whitespace reaches the wire as an href
`BRIDGE_ROUTE_HREF_PATTERN` refuses, the handoff falls through to the local
router, and expo-router's Unmatched paints over the page.

Deleted rather than patched: `hostNewWorktreeSessionRoute` already builds
this exact href with both segments encoded, and already has the test that
pins it. The screen now calls it.

The census that caught it stays: no module under `src/tasks` may interpolate
into `/h/${...}` without encoding, which is the rule rather than this one
line. Three refactor-parity hashes move with the statement change and are
recorded in that file the way every earlier movement is.

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

* feat(mobile): route the tasks tree's external links through the seam (OTA phase C, C2.1)

Ten of the twelve call sites in the tasks page closure: the nine under
`src/tasks`, swapped by one export in the dependency barrel, and
`MobileMarkdown.tsx`, which imports react-native directly and is edited in
place.

Inside the shell's WebView react-native-web's `openURL` calls
`window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
`createWebViewWith`, Android false from `onCreateWindow` — and resolves
regardless. Every one of these sites would have reported success into a tap
that opened nothing.

The barrel's `Linking` is typed `{ openURL: (url: string) => void }`, so a
`.catch` on it is a compile error rather than a handler for a rejection that
cannot arrive; the seam names its own failures. `MobileMarkdown`'s own
`.catch(() => {})` goes with the swap for the same reason.

No parity hash moved: the barrel and `MobileMarkdown` are outside the
refactor-parity family's source set.

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

* feat(mobile): route the shared screens' external links through the seam, with a census (OTA phase C, C2.1)

The last two of the twelve call sites in the tasks page closure:
`ProtocolBlockScreen.tsx` and the `openExternalUrl` prop wiring at
`host-screen-overlays.tsx`.

Both are shared with native routes and with the already-live `/h/[hostId]`
page, so this changes that page too: its external links go from the measured
`window.open` no-op — which both shells refuse and which resolves anyway — to
a URL handed to the shell. Nothing changes on a phone, where the seam is
`Linking.openURL` unchanged.

The `openExternalUrl` prop chain is retyped `(url: string) => void` with it,
and `SmartWorkspaceSourceField`'s `.catch(() => {})` goes: the seam names its
own failures and never rejects, so that was a handler for a rejection that
cannot arrive.

The census is the rule rather than today's twelve sites: no module in the
tasks page closure may reach react-native's `Linking`, by name or through a
namespace import. It reads the closure from a new builder export —
`metafile.inputs` for `_layout` plus the route, which is one definition of
what a page contains — and checks which module the name comes from, not which
text a call site writes, since the tasks tree still calls `Linking.openURL`
and that `Linking` is now the barrel's seam-backed export. Confirmed to
discriminate: restoring one react-native import turns it red.

A second case pins that the seam is in the closure, so an empty offender list
cannot also mean a page that reaches no link code at all.

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

* feat(mobile): write the tasks clipboard through the shell's verb (OTA phase C, C2.1)

The two `Clipboard.setStringAsync` sites in the tasks page closure move onto
a seam, `src/platform/clipboard.ts` with a `.web.ts` sibling, registered in
the overrides.

A hook rather than a function because the web form needs the page's bridge
client, which is React context. Native is `expo-clipboard` unchanged. Web
calls `native.clipboard.write` through `useNativeVerbs`, because
`expo-clipboard` on the web is `navigator.clipboard` and needs a secure
context: the iOS shell serves the page from a custom scheme and Android from
`https`, so that path would work on one platform and silently not on the
other, with nothing at the call site able to tell.

Both seams reject rather than return false, and both call sites already wrap
the write in a `catch` that puts the message on screen — so a write that did
not land says so instead of showing "Copied". A route that has not declared
`native.clipboard.write` is refused before a frame is sent and lands in that
same `catch`; the route declares it in the entry commit.

Two parity hashes move, the hook list and the statement hash, each by one
entry, and are recorded in that file. `semantics` holds, as do render and
style: no RPC call, method literal or JSX host signature changed.

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

* feat(mobile): hand the tasks Back button to the shell (OTA phase C, C2.1)

The tasks header's `router.back()` reached expo-router through the dependency
barrel, and inside the page that moves nothing: the document holds the single
history entry the entry wrote with `replaceState`. The stack with somewhere
to go is the native one the shell pushed the page onto.

One line in the barrel, as with `Linking`: `useRouteHandoff` is router-shaped,
so every call site is unchanged. On a phone it is expo-router. Inside the page
it keeps a route the page renders and posts `navigate-back` for a Back the
document cannot serve — the C2.2 seam, which until now had no consumer.

No parity hash moved: the barrel is outside the refactor-parity source set,
and no call site changed.

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

* feat(mobile): render mermaid as its own source box on the web (OTA phase C, C2.5)

`MermaidDiagram` is in the tasks page closure, reached through
`MobileMarkdown`, and it renders the diagram inside a sandboxed `WebView`.
`react-native-webview` is a native component with no browser counterpart:
importing it runs a codegen lookup that throws, and the route manifest imports
every route, so one such import takes the whole page down rather than one
diagram.

The web sibling renders the labelled source box the native component already
falls back to on a parse or render error, with that component's own styles, so
the degradation looks like a state the product already has rather than a
second design.

Not a browser renderer, and the reason is not reach: mermaid is a browser
library and the engine bundle is vendored. It is that the native path's safety
comes from the WebView it runs in — `buildHtml` escapes `</script>` and the
U+2028/U+2029 separators because diagram source is untrusted agent and PR
content — and a DOM path has no such sandbox, so it needs its own escaping and
its own proof. That is a change of its own, not a smaller version of this one.

Registered in the overrides, whose gate fails on an unlisted `.web.*` file.

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

* feat(mobile): turn the tasks route on for the page (OTA phase C, C2.1)

The entry: `/h/[hostId]/tasks` joins `MOBILE_WEB_PAGE_ROUTES`, the route file
becomes the shell's flag switch in `index.tsx`'s shape, and a `.web.tsx`
sibling renders the screen directly, registered in the overrides.

The screen moves to `src/tasks/MobileTasksScreen.tsx` first, verbatim — body
byte-identical, imports rewritten to `./`. It has to: under the builder's
`resolveExtensions` a web sibling importing `./tasks` resolves back to
itself, which is why every other shell route's screen already lives in `src`.

The parity family follows the file rather than the path. `TASKS_ROUTE` leaves
`MOBILE_TASKS_SOURCE_FILES` — `SOURCE_PATTERN` already matches
`MobileTasks*.tsx`, so listing it too would double-count — and the execution
reader points at the new file. Measured rather than predicted: all six
refactor-parity cases pass unchanged. No hash moved, including the family
text and declaration list, because the new name sorts where the route path
sat.

The route declares `navigate`, `storage`, `externalLink` and
`native.clipboard.write`, which the grammar fold made expressible and
per-route scoping makes meaningful: it is granted those and not the rest of
what this shell implements.

The browser check covers what only a browser answers — every module in the
closure evaluating under React Native Web, `taskSource` surviving the
handshake into the page's own URL, and the route's chunk arriving on a
client-side navigation. It states plainly what it does not cover: the three
seams are reached from controls that need provider data the double does not
serve, so a case posting those frames directly would prove the transport and
read as a tap it never performed. Both new checks join the `mobile_web_app`
job.

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

* fix(config): resolve a route closure the way the bundle ships it (OTA phase C, C2.1)

`mobileWebAppRouteClosure` took the route's explicit `.tsx` path as an entry
point, so esbuild used that file directly and `resolveExtensions` never ran.
For a route with a `.web.tsx` sibling that measured the native switch, which
no browser loads: the tasks closure came back carrying
`MobileWebShellScreen`, and with it a `Linking` import the census then
reported as an offender.

Extensionless now, so the closure is the one the page actually contains:
3775 modules, 428 local, with `external-link.web.ts` and `clipboard.web.ts`
in it and the shell screen out.

The route-manifest pins move with the tasks route joining
`MOBILE_WEB_PAGE_ROUTES`, in both the declaration check and the built
manifest.

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

* fix(mobile): cover the clipboard seam, close two page escapes, share the mermaid props (OTA phase C, C2.1)

Four from round 1.

The clipboard seam shipped untested. Both halves have one now: the native
form rejects when `setStringAsync` answers false and resolves when it does
not, and the web form is driven through the real port pair — resolving on a
reply, rejecting when the shell says the pasteboard refused, and rejecting on
an ungranted route without putting a frame on the wire.

The tasks barrel still re-exported `expo-clipboard` with no consumer, which
kept `ExpoClipboard.web.js` — the `navigator.clipboard` path this series
exists to avoid — inside the page closure. Deleted, and asserted as the
module's absence from that closure rather than as a count of importers: a new
import puts the file back whoever writes it.

`ProtocolBlockScreen` reached expo-router's singleton for its way out to the
host list. A singleton is the one shape the handoff cannot intercept — it is
not a hook, so the page's bridge client is never consulted — and `/` is a
route the page does not carry, so inside the shell that replace rendered the
root route in the WebView instead of leaving it. Pre-existing and live via
`/h/[hostId]`; routed through the handoff now. Two suites' `expo-router`
mocks gain the hook the handoff reads.

`MermaidDiagram.web.tsx` redeclared its props; it imports the native
component's type, so drift fails tsc.

No parity hash moved: none of these files is in the refactor-parity source
set.

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

* style(config): use endsWith for the clipboard module check

The changed-code gate refuses a dollar-anchored regex where `String#endsWith`
says the same thing. No behaviour change.

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

* fix(mobile): close the href census gap, read route params through firstParam (OTA phase C, C2.1)

Five from round 2, two of them real.

The raw-interpolation census inspected only the leading `${...}`, so
`` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` passed it — and
a worktree id carrying `/`, `#`, `?` or whitespace breaks the href exactly as
a host id does. It now refuses any hand-built `/h/...` template with any
interpolation left raw, whichever segment it is. Proved against exactly that
shape in a throwaway before the change, which the old rule admitted.

The tasks switch read `hostId` and `taskSource` as plain strings. expo-router
hands back an array for a repeated query key, so a duplicate `?hostId=` built
`/h/host-a%2Chost-b/tasks`; both go through `firstParam` now, as the
agent-history switch does. `index.tsx` is untouched, per the Phase D list.

Three in the render check's prose: the header claimed the browser proves the
three seams fire from a tap, which the file's own closing note denies; a
module count repeated a number the closure test already pins; and a `replies`
parameter was threaded through without ever being supplied.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 13:40:26 -04:00
Jinwoo Hong 40b2230508 test(mobile): typecheck the test files on a ratchet, and pin the reply enums where tsc looks (#21298)
* fix(mobile): move the last six reply-enum pins where tsc looks

mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>`
coverage record in a schema test is never typechecked: the two that existed
(SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four
closed enums beside them had only a doc citation of the host type.

Each arm list moves into its schema module as hostUnionArms<Union>(), which
#21269 introduced for the same reason, and each test iterates the exported
list instead of holding its own copy:

- SSH_CONNECTION_STATUS to SshConnectionStatus
- PROJECT_OWNER_TYPE to GitHubProjectOwnerType
- DETAIL_FILE_STATUS to GitHubPRFile['status']
- PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal
  arms of MobilePushTestResult and MobilePushRegisterResult
- SETUP_RUN_POLICIES to SetupRunPolicy

openEnum's parameter widens from a non-empty tuple to `readonly string[]` so
a hostUnionArms list can feed it. z.enum already accepts the same, so the
tuple constraint only excluded callers zod itself takes; behaviour unchanged.

Twelve mutations prove the pins: dropping one arm and adding a bogus one
each fail mobile tsc in all six places. Zero goldens move, the schemas'
behaviour being unchanged, and the 21 recording suites pass at the existing
baseline.

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

* test(mobile): fix the type errors in eighteen test files

Found by typechecking the tests for the first time (see the config that
follows). All mechanical, none weakens a product type:

- 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils
  where act wants void, so each becomes a block. The async ones await only a
  genuinely promise-returning call, so no extra microtask tick is introduced.
- Four fixtures were stale against a product type that gained a required
  member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError,
  the branch-compare summary's errorMessage, and SessionOptionDescriptor's
  transport, which #20884 added precisely so a producer could not inherit the
  wrong lane's rendering by omission.
- `getLastConnectedAt` on the shared relay fake was typed `() => null`, which
  refused the timestamp two escalation suites assign to it.
- Two holders used before assignment take `!`, one `advance!.kind === ...`
  becomes `advance?.kind`, one widened status arm takes `as const`, and the
  Expo notification fixture keeps `data` required because the dismissal cases
  assign through it.

631 test files pass, 6222 tests, unchanged.

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

* test(mobile): typecheck the test files, on a ratchet

mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the
release bundle, and vitest transpiles without checking types. Nothing had ever
typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written
in one proved nothing and why 144 of the 630 test files had drifted.

tsconfig.test.json is that program with the tests put back, behind
`typecheck:tests`. Four files stay out: they import the desktop main process or
src/shared/child-process, which are written against @types/node, and this
program's libs are React Native's, where setTimeout answers a number rather
than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the
desktop rather than about mobile; vitest runs those four under Node, which is
where they belong.

The CI gate is a ratchet rather than the raw typecheck, modelled on
check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that
set and fails when a file that checks today stops checking, or when a baseline
entry starts checking and was not pruned. The list may only shrink.

Why not zero: 180 of the remaining 510 errors are one seam — tests locate
mocked react-native components by string name, which `ElementType` does not
admit — and closing it means either 180 casts or a global JSX declaration for
the mocked names. That is a design decision, not a mechanical fix, so it is
left for a follow-up rather than made here. The rest are smaller clusters of
the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing,
and createElement props fixtures.

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

* docs(mobile-recorder): correct the corpus counts and the salvage claim

The oracle section still quoted the corpus as 368 scenarios and 727 goldens;
it is 393 and 778, and the three replay suites report 781 tests. Each number
now names the command that measures it.

"No golden carries one" was the load-bearing error: 44 goldens carry a
recorded `reply-salvage` today, starting with the push-test unknown-reason
scenario #21176 added for exactly that purpose. The paragraph claimed the
observation pins an absence when on those families it pins a recorded drop.

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

* test(mobile): pin the tests-typecheck ratchet's parser

The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail
under an error. Counting those as filenames would write unparseable entries
into the baseline and leave the gate unprunable, so the parser is pinned on
that shape as well as on the added/stale diff.

Written against the gate itself: it flagged this file before the directive it
carried was removed, which is the end-to-end proof the spawn half works.

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

* test(mobile): await the timer advances the act() rewrite dropped

Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a
braced body left the returned promise floating at 27 sites, so the advance
was no longer ordered before the assertions that follow it.

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

* test(mobile): unshadow MobileHostCard's .tsx suite

A wildcard `include` keeps only the higher-priority extension, so
MobileHostCard.test.tsx sat outside every tsc program while
MobileHostCard.test.ts existed beside it. Its one error is the same
react-test-renderer seam its sibling is baselined for.

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

* test(mobile): census every test file into the typecheck program

The ratchet diffs only files that error, so a test excluded from
tsconfig.test.json or shadowed by a sibling extension left the gate
silently. Every *.test.ts(x) on disk must now be in the program or
named in TESTS_OUTSIDE_PROGRAM with its reason.

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

* fix(shared): make the enum helpers refuse the ways they can prove nothing

openEnum takes a `const` T so a bare literal keeps its arms rather than
widening to string. hostUnionArms blocks inference of U with NoInfer and
defaults it to never, so a call that omits the host union — where the
record would only pin itself — no longer compiles.

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

* docs(mobile): describe the census and correct the baseline count

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

* test(mobile): give the push fixture cast its SAFETY rationale

Widening the pre-existing cast made the changed-code gate attribute it as
a new finding.

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

* test(mobile): build the push fixtures as typed notifications

Replaces the `as unknown as` cast with Expo's own types, filling
FirebaseRemoteMessage and its notification once in two builders, and
passes the data payload in rather than mutating through an optional
member. Typing the fixture showed one assertion comparing the scheduled
content against the whole arriving content, which only held while the
cast let the fixture omit the two members the presenter drops; it now
names the four members the presenter forwards.

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

* test(mobile): keep the grouped-question advance read non-optional

`advance?.kind` let an absent advance take the null-draft branch instead
of failing.

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

* fix(mobile): run the tests-typecheck ratchet on Windows

Spawns tsc's JS entry on this Node instead of the node_modules/.bin
shim, which is a POSIX shell script that Windows resolves to tsc.CMD and
then appends .exe to. Parsed paths are normalised to POSIX so a Windows
run does not read every baseline entry as both stale and added.

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

* test(mobile): close the ratchet's @ts-nocheck hole and read tsc once

tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed"
with one line, pruned, and never checked again; the census now names any
program test file whose leading comment carries the directive.

`--noEmit --listFiles` answers both questions in one pass, so the gate
spawns tsc once rather than twice. Corrects the two stale counts, and
states hostUnionArms' real reason for living in the schema module now
that tests are typechecked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 18:58:25 -04:00
Jinwoo Hong 6142657d7a refactor(mobile): checked reply readers for the tasks domain's board, runtime, search and create (step 7) (#21246)
* test(mobile): record main's agent.launch create receipt before checking it

`agent.launch` is the one read site in the tasks domain's project-board,
runtime, source-search and workspace create/source files with no recording
family at all, so main's answer to a malformed launch receipt was undocumented
and a checked reader would have had nothing to move.

One family, one scenario, two goldens: `worktree.agent-launch-create` drives
`createWorktreeWithNameRetry` down the `agent.launch` arm instead of
`worktree.create`, which needs an `agentLaunch` argument on the existing
worktree-create-retry adapter. The agent is a constant there on purpose — which
agent is picked changes only the params, and the arm under test is which method
the create is issued on.

A separate family rather than an eighth `worktree.create-retry` scenario:
`familyGoldens` drives its reply matrix over the family's FIRST scenario, so
adding to that family would have recorded a pilot golden and left the launch
receipt with no partitions. As its own base it gets all eleven.

Recorded from a detached worktree at the pinned baseline with this branch's
`rpc-recording/` and manifest copied in, per the recipe in the recorder README:
`mobile/pnpm-lock.yaml` has drifted past `4b876758d3` on main, so `--record`
refuses on this branch's tree even though `mobile/src` and `src/shared` are
byte-identical to the pin.

Thirty-four existing goldens move on `adapterSha256` and nothing else — the six
families mounted through the edited adapter module. No body moves.

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

* refactor(mobile): checked reply readers for the tasks domain's board, runtime, search and create

Forty-three unchecked reply readers across five files become checked zod readers,
so a malformed host reply surfaces as one readable error naming the method
instead of a downstream TypeError, a rendered `undefined`, or a screen left ready
over garbage. Deliberately a behaviour change on malformed replies only.

Five schema modules, each recording the consumer line behind every requirement
and the host handler it was checked against:

- `task-project-board-reply-schema.ts` — the sixteen `github.project.*`
  envelopes. Where a consumer reads a member off BOTH arms unguarded the schema
  is a union on `ok`; where it guards everything (`result.error?.message ?? '…'`,
  `result.labels ?? []`) it is a flat passthrough and requires only the
  container, because a requirement on a member the consumer already defaults
  would refuse a reply main rendered.
- `task-runtime-reply-schema.ts` — the hydration reads. The three preference
  writes read `z.unknown()`: no call site interprets their body.
- `task-source-search-reply-schema.ts` — the provider searches and the pasted
  single-item lookups. The Linear union replaces the hand reader in
  linear-mobile-issue-read.ts, whose own copy reached the screen unattributed.
- `workspace-source-reply-schema.ts` — SSH state, agent detection, orca.yaml
  hooks, sparse presets and base-ref search.
- `workspace-create-reply-schema.ts` — the create receipt, the launch receipt and
  the hosted-base union.

Requirements are exactly the members a consumer reads unguarded AND a recorded
golden shows the host sending. That second half is load-bearing: the recorded
GitHub search row is `{ number, title }`, the recorded Linear issue is `{ id }`,
the recorded project is missing `id`/`url`/`source` and the recorded sparse
preset is missing `repoId`/`createdAt`/`updatedAt` — requiring what the shared
types declare would have dropped rows main renders. Where the value therefore
stays looser than the screen's own state type, the call site keeps one narrowing
cast with that reason on it rather than a default that would fabricate state.

Two enum decisions, both pinned:

- `ownerType` is CLOSED with no fallback. It is echoed into the next
  `github.project.listViews` params, and remote-wire-compatibility.md rule 4
  forbids a reply-schema fallback from shaping a param; the host's own listing
  handler answers `validation_error` for any other value.
- `ssh` `status` is OPEN and degrades to `disconnected`, main's own answer for a
  state it did not receive. The readiness gate is an equality test against
  `connected`, so an arm this build has not heard of can never grant a create,
  and the record survives with its Connect affordance.
- Every other host vocabulary a consumer equality-tests — the project view
  `layout`, the `setupRunPolicy` — stays `z.string()` for the same rule.

Tri-states are preserved, not collapsed: the row detail's `reviewDecision`, a
work item's `author` and the SSH record's `error` each keep explicit `null`
distinct from absent, with a unit pin on each.

`blank-workspace-create.test.ts` splits one `it.each` in two. The two create
routes now answer a workspace-less reply differently: `agent.launch` still
reports "Failed to create workspace", because its reader guards `worktreeId`
itself, while `worktree.create` is named as unreadable, because the create screen
reads `result.worktree.id` unguarded into the session route. Both reach the same
catch; only the sentence changes.

`mobile-tasks-refactor-parity.test.ts` moves four hashes and no count. Hooks hold
at 350 with 28 bodies edited and no dependency array moved; statements hold at
417 and declarations at 194; `semantics` loses exactly four lines, all four
string literals that lived inside the one deleted inline cast type. No method
literal and no `rpc:` call signature moves.

The inventory loses its five tasks lines; the boundary test stays green.

Goldens are refreshed in the next commit, which is where the disclosed behaviour
change is proved.

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

* test(mobile): repin and re-record the corpus over the tasks domain's checked readers

Repins `baseline` to d4cfac98b4, the commit that landed the checked readers, and
re-records all 760 goldens. The repin rewrites that header on every file; the
recorder edit below rewrites `recorderSha256` on every file too.

The disclosed behaviour change is the body-moved set and nothing else: the
malformed reply partitions of the families whose readers this branch converted.

Two recorder files move with it, both re-anchoring evidence the checked readers
displaced rather than deleted:

- `pilot-recordings.test.ts` restates the b2 seed. The shipped null result is
  still the seed and the screen still reports an error the user can see; what
  moved is the sentence, from V8's "Cannot read properties of null (reading
  'ok')" to the reply and method the reader names.
- `operation-mutations.ts` re-anchors that seed's `acceptance` mutant. Its defect
  is a null envelope reaching the metadata sheet, and the call-site guard it was
  injected at can no longer see one, because the reader refuses the envelope
  first. The anchor is the schema now, and loosening it to `z.unknown()` puts the
  null back on the path to `result.ok` — the same defect at its new home.

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

* test(mobile): assert the reply-schema pins without type assertions

The changed-code casting gate counts a `as` in a test like any other, and eight
of them had crept into the new schema pins. Each is replaced by an assertion that
reads the same fact off the typed value: the schema already declares
`worktreeCreateIdempotency`, `glab`, `status` and `error`, so the narrowing was
never needed, and the two "is this key present" checks are JSON comparisons,
which is the honest way to ask — `JSON.stringify` drops an absent key and keeps
an explicit null, which is the whole distinction a tri-state pin is making.

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

* test(mobile): repin the corpus to the tree it was recorded from

The previous repin named d4cfac98b4, and the assertion cleanup that followed it
touched `mobile/src` — a fenced path — so the header pointed at a tree the
working copy no longer was. Repins to 6b740c3f61 and re-records.

Bodies are unchanged: only `baseline` moves, on all 760 goldens. Four test files
cannot reach a recording, which is the point — the fence does not know that, and
a header that names a tree nobody can reproduce is the one claim it exists to
make.

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

* docs(mobile): point the reply schemas' consumer citations at the landed lines

Every requirement in the five schema modules names the consumer line that
justifies it, and the migration moved those lines: deleting a thirty-line inline
cast type shifts everything under it. The citations now resolve against the tree
they ship in.

Comment-only. No schema, no reader and no consumer changes.

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

* test(mobile): repin the corpus to the tree the citations landed in

`baseline` follows the last commit to touch a fenced path, and the citation fix
did. Bodies unchanged: `baseline` moves on all 760 goldens and nothing else.

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

* test(mobile): pin both reader vocabularies against the host's own unions

The two enums these readers declare were checked against mobile's restatement
of the wire, not against the types the handlers return. A closed enum written
from the wrong vocabulary drops every row that carries an arm it omits, and no
golden can catch it when no fixture carries one.

Both arm sets are now keyed by the host type in a Record, so an arm added to or
removed from SshConnectionStatus or GitHubProjectOwnerType fails tsc before any
test runs. The SSH degrade's inertness is pinned at the gate that reads it
rather than argued in a comment: an arm a newer host sends and the degraded
value reach the same label, the same readiness verdict and the same error.

Also corrects a comment claiming the file-mutation owner check reads members
this schema forwards. It asks ssh.getState through a reader of its own, and no
mobile code reads providerEpoch, supportsFolderDownload or remotePlatform.

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

* test(mobile): repin the corpus to the tree the vocabulary pins landed in

Comments and tests cannot change a decoded value, so the whole delta is the
baseline header key: 760 goldens, one line each, no body moves and no scenario
or adapter change.

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

* test(mobile): record real provider rows in the smart-search and paste fixtures

The smart-search and paste-lookup scenarios carried hand-written stubs, not
rows any handler can build: Linear issues of `{ id }` alone, GitHub items of
`{ number, title }`, and a GitLab item keyed by `iid`, a member neither work-item
type declares. Every one of them omits members the host's own types declare
non-optional and mobile then reads with no guard, so the corpus was evidence for
a requirement it could never have justified.

The rows are now the shapes the corpus already uses elsewhere (`tk-list-linear`,
`tk-provider-load`, `tk-list-gitlab-items`), checked member for member against
LinearIssue (src/shared/linear/issue-types.ts:3), GitHubWorkItem
(src/shared/github/work-item-types.ts:17) and GitLabWorkItem
(src/shared/gitlab-types.ts:165).

No schema moves in this commit. It records what main renders for a real row, so
the requirement that follows can be read against main's own behaviour rather
than against a stub.

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

* refactor(mobile): require the members the tasks readers' consumers read unguarded

Round-1 review found four unguarded consumer reads at members the corpus proves
the host sends, plus two the corrected fixtures now prove. Each one ends in a
TypeError inside a render or a useMemo, which is the defect class this migration
exists to close.

Required, each because a consumer reads it with no guard and the host's own type
declares it non-optional:

  title on an accessible project  project.title.toLowerCase()
  name, directories on a preset   localeCompare, and two joins
  labels on a work-item row       item.source.labels.filter, both label editors
  state.name, team.name, priority createLinearTask, and the reviewer sort

All six sit inside a salvagingArray, so a row that lacks one drops and the list
survives. The single-row paste lookup names the reply instead, because there is
no list for it to survive in.

Loosened in the other direction: the SSH record no longer requires `error` or
`reconnectAttempt`. Nothing reads either one — the gate spells
`matchingState?.error ?? null` and nothing anywhere reads reconnectAttempt — and
the record is a salvagedOptional, so requiring an unread member drops the WHOLE
record, whose fallback on the connect path is `fallbackSshState(id,'connected')`.
A reply of `{ targetId, status: 'auth-failed', error }` would have shown the
drawer as connected. Mobile's own stored type is widened to match; the shared
wire type is untouched.

The `iid` extension on the lookup row goes with it. Neither work-item type
declares such a member and every GitLab consumer builds its iid param out of
`item.source.number`.

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

* test(mobile): repin the screen parity hashes over the merged tree

Main landed the sibling tasks lane, which edits the same screen hook files this
branch does, so the merged tree hashes to neither side's constant. Both inputs
are legitimate: main's reply-schema conversions and this branch's WorkspaceSshRecord
rename.

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

* test(mobile): justify the smart-source casts by the schema, not the fixture

Five SAFETY notes argued from the corpus back to the wire: they justified a
cast by the stub rows the fixtures used to carry (`{ iid, title }`,
`{ number: 12, title: 'twelve' }`). Those rows were the defect corrected in
6763ff12e9, so the claims are now false, and the reasoning was never sound —
a fixture cannot say what the host may send. Each note now cites the schema's
own requirement rule, the host type and the consumer read.

Comment-only; no golden moves. The hook and statement parity hashes move
because `normalized` hashes a statement's full span, comments included.

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

* docs(mobile): repair the line citations this lane's own edits aged

Eleven citations across five reply-schema modules pointed at the wrong line.
Every one was correct when written and rotted afterwards: the SAFETY-note
rewrites, the F4 dedupe's deleted casts and the sibling lane's merge each
shifted the files being cited. A citation is the whole argument for a
requirement, so a stale one reads as a fabricated one.

Found by resolving every `file.ts:line` in the five modules against the merged
tree and comparing the line's text to the claim beside it, not by reading them.
The ones that still resolve correctly are left alone, including three that
looked stale and were not.

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

* fix(mobile): keep the two advisory task probes total so a nullish payload cannot unhydrate the screen

`preflight.check` and `linear.status` are read under `success-result-or-skip` and documented as
advisory. That policy accepts an envelope whose `result` is absent or null, then asks the reader to
decode it; a `looseObject` refuses, the throw leaves the reader, and the caller's catch discards the
entire hydration. The corpus records the difference: on the `result-absent` and `result-null`
partitions main hydrates the Tasks screen and lists one provider, and the checked readers left it
unhydrated with no providers.

`.catch` restores main's answer exactly. Every consumer guards to the leaf and compares to `true`,
so absence, null and a garbage payload have always meant "not installed" and "not connected".

Four cases pin it, and removing either catch fails all four.

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

* test(mobile): re-record the RPC corpus at this lane's head

Repins the recording baseline to b354d1338a (the advisory-probe totality
fix) and records all 382 scenarios from that tree.

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

* test(mobile): re-record the RPC corpus after merging main

Repins the recording baseline to the merge commit and records all 386
scenarios from that tree, so the corpus carries both main's step-7 batch
and this lane's.

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

* refactor(mobile): drop the Linear row cast the checked schema made unnecessary

`found` is already assignable to the mobile `LinearMobileIssue` alias once
`linearIssueRowSchema` requires its nine members, so the assertion and its
disable line carried no type error. The sibling cast in
smart-source-search-requests.ts stays: it targets the shared `LinearIssue`,
whose `labelIds` is required where the schema leaves it optional.

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

* docs(mobile): rewrite the SAFETY lines that argued from deleted fixtures

Three casts still justified themselves with the `{ id }` and
`{ number, title }` rows this branch replaced in round 1, which reads as a
licence to loosen the requirements that close the reproduced crashes. Each
now names what its schema requires and what the cast actually covers, each
verified by deleting the cast and reading the error:

- Linear rows: all nine read members are required, so `labelIds` alone is
  the gap between the schema and the shared LinearIssue.
- GitHub search: `items` and eight row members are required; the salvaged
  `T | undefined` types and the deliberately opaque `sources`/`errors` are
  what remain.
- Sparse presets: `id`, `name` and `directories` are required; the cast
  covers the three SparsePreset declares that the reply omits.

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

* docs(mobile): resolve the bare :NN citations the first audit could not see

The earlier repair resolved only qualified `file.ts:line` citations, and
these doc blocks name a file once and then reference it as a bare `:NN`,
so every continuation reference went unchecked. Re-running the audit with
a resolver that carries the last-named file and directory forward finds
twelve stale line numbers and four references whose nearest named file is
the wrong one.

Fixed: the four `find`/`filter` lines and the layout equality tests under
the `views` requirement, the settings commit, the detail refusal throw,
and the three metadata guarded reads, all shifted by one to five lines.
The ui-state, paste-resolved and host-method references are now qualified,
because an intervening citation to another file silently reassigned them.

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

* test(mobile): re-pin the tasks parity hashes the round-2 fixes moved

One statement changed (the Linear list cast is gone) and three SAFETY
comments nested inside statements were rewritten, so the hook and
statement hashes move. Counts hold at 350 and 417, and the declaration,
semantic, render and style hashes do not move, which is what shows no
type, call or rendered tree changed with them.

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

* refactor(mobile): delete the Linear list reader the checked schema replaced

`extractLinearIssueReadItems` lost its only caller when the smart-search
operation moved to `rpcResultVariant('linear-issues', …)`. What remained
was a function no screen can reach, a suite reporting coverage for it, and
a second 'Unexpected Linear tasks response' string competing with the
named reply error. The `LinearMobileIssue` type stays: it is the mobile
`LinearIssue` alias.

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

* docs(mobile): say what a refused sparse-preset list actually does to the screen

"Reports the named error" overstates it: the error setter's value is
destructured with a leading underscore and read by nobody, here and on
main. The visible delta is `presetsLoaded` staying false, which disables
"New preset" and both draft entry points where main let the user create
one. No shipped host reaches the state: `repo.sparsePresets` has no
refusal arm.

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

* test(mobile): re-record the RPC corpus after the round-2 fixes

Repins to the round-2 head and records all 386 scenarios from it.

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

* test(mobile): re-record the RPC corpus after merging main at 7a1f55c52a

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

* fix(mobile): keep the persisted-ui-state reader total so an unreadable ui reply cannot unhydrate the Tasks screen

Main boxed the payload and read `undefined` off a string, number or array, so the screen
hydrated; a refusal here threw out of hydrateTaskState and failed the settings, preflight and
Linear legs beside it. Null and absent now hydrate with the defaults too, since every read of
the state is optional. The GitHub search SAFETY line separates the members the schema requires
from the ones it only types, and the parity hashes follow that comment text.

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

* test(mobile): re-record the RPC corpus over the total ui-state reader

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 14:08:59 -04:00
Brennan Benson abc8386e14 fix(mobile): name a create's launch so a lost reply cannot build two workspaces (#21137)
* fix(mobile): name a create's launch so a lost reply cannot build two workspaces

`agent.launch` admits a caller-supplied `operationId` through a durable ledger, so
exactly one execution happens and every replay returns the recorded answer. No client
sent one, so the machinery was inert and the original defect was still live: mobile
retries a lost create by design, and a retried launch built a second agent in a second
workspace.

Mobile now mints an operation id per create candidate and sends it whenever the host
advertises `agent.launch.replay.v1`.

The invariant is one operation per candidate. `computeAgentLaunchFingerprint` folds
`target` whole, so the workspace name is inside the fingerprint; carrying one id across
a name-collision bump would meet its own row under a differing fingerprint and refuse
`agent_session_operation_conflict`, failing the create outright on the second candidate.
The id is therefore minted beside `clientMutationId` at the top of each loop iteration
and reused verbatim by every retry arm inside that candidate — never re-minted, since a
new id is a new operation.

Admission runs ahead of every effect, so `_invalid` / `_expired` / `_capacity` prove
nothing launched: those re-send the same candidate unnamed rather than let bookkeeping
fail a create the host would have performed. `_unknown` is the one refusal that is not
safe to re-send, and it surfaces.

Also corrects a false comment: the legacy path caches the whole launch under
`clientMutationId`, so inside its 60s window a replay adds neither a workspace nor a
surface, and outside it adds both — not "a second surface, never a second workspace".

* fix(mobile): preserve launch identity on refusals

* fix(mobile): use launch receipts to authorize replay

* test: move mobile launch replay coverage outside node project

* fix(mobile): enforce replay-safe launch delivery at the host

* test: run mobile launch contracts in mobile checks

* test: cover mobile launch contract workflow dependencies
2026-09-17 10:06:11 -07:00
Jinwoo Hong 01a1b6b024 refactor(mobile): checked reply readers for the tasks item and list domain (step 7) (#21169)
* refactor(mobile): checked reply readers for the tasks item and list domain (step 7)

Thirty-eight unchecked reply readers across four tasks files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`,
or a sheet left ready over garbage. Deliberately a behaviour change on malformed
replies only; nothing on the wire moves.

  mobile-task-item-state-operations.ts    17
  mobile-task-item-detail-operations.ts    8
  mobile-task-item-comment-operations.ts   7
  mobile-task-list-operations.ts           6

Two rules decide every schema, and both are stated in
task-provider-entity-reply-schema.ts:

1. A member is required only where a tasks consumer reads it with no guard.
   Everything reached through `?.`, `??` or a `typeof` test stays optional,
   because a reply without it rendered the same fallback then and now.
2. No member is required that the site's own recorded `normal` reply lacks. The
   corpus is the only evidence of what a host really sends at each site, and
   requiring a member absent from that control would turn a good reply into an
   incompatible one.

Rule 2 holds two schemas at the container: `github.prFileContents`, whose
recorded reply is `{ oldContent, newContent, truncated }` where
`getPRFileContents` returns `{ original, modified, ... }`, and `gitlab.todos`,
whose recorded row is not a `GitLabTodo` and whose `normal` partition therefore
records main crashing in `actionName.replace`. Both still gain their container,
which is what names a reply that is not an object or not a list. Correcting
those two scenarios is the follow-up that unlocks narrowing the rows.

Nine writes share one envelope reader and five comment writes share another:
`ok === false` and `error` are one host convention across them, and no input
would make two of them want different answers. The acceptance, the name and the
recorded family stay per operation. Three readers are reused rather than
re-declared — the session domain's boolean confirmation for `setPRFileViewed`
and `resolveReviewThread`, and its salvaged-member combinators throughout.

Three call-site shape tests the reader now answers for are deleted: both
`Array.isArray(payload)` guards on the checks read and the
`typeof count === 'number'` fallback on the item count. `GitHubPRFileContents`
is widened to optional members, which is what the reader can promise, and
`buildGitHubPrFileDiffPreview` takes the widened sides — `splitContentLines`
already treated a falsy side as no content, so no runtime behaviour moves.

The tasks source-parity hashes are refreshed: hook, statement, declaration and
render-token counts are unchanged, the render-token hash does not move at all,
and `semantics` is a pure deletion of ten lines.

Inventory: 137 unchecked readers over 30 files becomes 99 over 26.

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

* test(mobile): repin the RPC recording corpus and re-record the tasks reply deltas

`baseline` moves to 9133c02c5b, the commit that made the tasks item and list
readers checked, and the whole corpus is re-recorded from it. The repin rewrites
the `baseline` header of every golden; the body moves are the disclosed
behaviour change and nothing else.

What moved, and why: a malformed reply at one of the thirty-eight migrated read
sites used to reach the consumer as the declared type and fail downstream — a
property read on `null`, `.map` on a string, a rendered `undefined`. It now
stops at the operation boundary as one `RpcIncompatibleReplyError` naming the
method, which each call site's existing `catch` shows where it showed the
`TypeError` before. Every move is confined to a malformed reply partition of a
`tasks.*` or `linear.issue-detail` family; no `normal` partition moves, and no
family outside the tasks domain moves at all.

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

* test(mobile): anchor the tasks reply readers' null-collapse mutant

One registered mutant for the class the session domain shipped twice before a
review caught it: the assignable-user row's explicit `avatarUrl: null` collapsed
into absence. `tk-item-detail-metadata` records that null in visible state, so
the pilot suite kills it; it also fails the unit pin beside the schema and both
of that family's matrix goldens, including their `normal` partitions.

Three by-hand experiments back the other two claims the corpus should hold.
Applied to the product source, run, reverted:

- Loosening a required member the consumer reads with no guard —
  `linearIssueSchema`'s `title` to a salvaged optional — fails the unit pin
  `one Linear issue: refuses a reply missing a member createLinearTask reads
  with no guard`, and `tsc` rejects it, because the loosened output is no longer
  a `LinearMobileIssue`. No golden moves: the reply matrix varies the envelope a
  host sends, never the shape of a row inside a result, so a row requirement is
  unreachable from the corpus and the unit pin is the only thing holding it.
- Loosening a *container* requirement is reachable: making
  `linearAccountStatusSchema` `.nullable()` fails one matrix golden,
  `tasks.provider-load: reply partitions at linear.status#1`, on the
  `result-null` partition, as well as its unit pin.
- Swapping one checked reader back to unchecked —
  `githubPullRequestChecksRead` to `rpcUncheckedPayloadReader` — fails two
  matrix goldens, `tasks.item-review-github` and
  `tasks.project-row-review-checks`, both at `github.prChecks#1`.

Nothing under `mutants/` is pinned by a golden header, so this moves no
recording.

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

* test(mobile): correct the gitlab.todos fixture to a real GitLabTodo row

The `tk-list-gitlab-todos` reply sent `[{ id, targetType, target: { … } }]`, a shape `listTodos`
never produces: the host returns `GitLabTodo[]`, whose row carries `actionName`, `targetTitle`,
`targetUrl`, `projectPath` and `updatedAt` flat. Main crashed on it —
`Cannot read properties of undefined (reading 'replace')` from `createGitLabTodoTask`, with the
list rendering as an empty inbox and a raw TypeError on screen.

That crash was being read as evidence: a reader could not narrow this row without "refusing the
site's only success control", when the control was never a success. The fixture is the defect, so
the fixture is what moves.

The row is now a real `GitLabTodo` (src/shared/gitlab-types.ts:219) and main renders it: one item
titled "A GitLab todo", subtitle `group/project #4`, status `review requested`.

Recorded from the pinned main tree, not from this branch, so the corrected fixture's main
projection exists as a golden before any reader touches it: a detached worktree at
`4b876758d3158a8eb6b798055d8db7c58d1cd4a9` with this branch's manifest laid over it and its
`baseline` set to that commit, per the recorder README's detached-pin recipe. Control: all 756
other goldens reproduced the base corpus byte for byte; only these two moved. Both keep
`baseline: 4b876758d3`, which is the tree that produced them.

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

* refactor(mobile): check the GitLab to-do row and drop its cast

With the fixture corrected, the row is readable, so the reader reads it. `gitlabTodoSchema`
requires the five members the screen reaches with no guard — `id`, `actionName` (read as
`actionName.replace`), `targetUrl` (what tapping the row opens, and the title's fallback),
`projectPath` (the subtitle and the repository badge's key and label) and `updatedAt` — and leaves
every guarded member optional: `targetTitle` behind `targetTitle || targetUrl`, `targetType` and
`targetIid` behind the two tests in `gitLabTodoTargetRef`, and `authorUsername` and `state`, which
this screen carries but never reads.

The list salvages: one unreadable to-do drops and the rest of the inbox still renders, which is
what the rest of this domain does with a row it cannot place. Nullish still reads as the empty
inbox the call site already read.

`GitLabTodo` in mobile-tasks-provider-detail-types.ts now says what the reader proves rather than
what the host declares, which is what lets the `as GitLabTodo[]` assertion at the call site go. It
was the last cast in this domain's consumers, and it was re-typing rows nothing had checked — the
phantom-field shape this series exists to remove.

Parity: the same three hashes the step-7 commit moved move once more, for the deleted cast and the
widened row type. Counts are unchanged and `semantics` does not move.

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

* test(mobile): repin the corpus after the to-do fixture and row check

Repins `baseline` to the commit that checked the GitLab to-do row and re-records all 758 goldens,
so the whole corpus is pinned to one tree again: the two `gitlab.todos` goldens were still pinned
to main's, which is the tree that produced their before-picture.

The disclosed move is the `normal` partition of `tasks.task-list-gitlab-todos`. That is the fixture
correction showing through, not a reader change: with a real `GitLabTodo` on the wire the list
renders one item where it used to render a TypeError. Every other body move in this refresh is a
malformed-reply partition, as before.

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

* test(mobile): correct the github.prFileContents fixture to the host's shape

The recorded `normal` reply at both `github.prFileContents` sites was
`{oldContent, newContent, truncated}`, which `getPRFileContents` never returns:
it answers `{original, modified, originalIsBinary, modifiedIsBinary,
originalTooLarge, modifiedTooLarge}` (src/main/github/pull-request-file-contents
.ts:121-128), with the two too-large flags set only where a side was skipped for
size (:54). Both sites' `normal` partitions therefore proved nothing about the
success path, and `githubPullRequestFileContentsSchema` cited that fiction as
the reason it could require nothing. Same defect class as the `gitlab.todos`
fixture corrected in 32bd65c134, found by round-1 review.

Before-picture recorded against main's own product tree, not this branch's: a
detached worktree at `4b876758d3`, this branch's `pilot-scenarios.json` copied
in with `baseline` set to that commit, per the recorder README's detached-pin
recipe. Control: 744 of the 758 goldens reproduce the base corpus byte for byte;
the 12 copied here are the two affected families, and the remaining two are the
already-corrected to-do pair, which reproduced 32bd65c134's bytes exactly.

The 12 carry `baseline: 4b876758d3`, the tree that produced them. The next
commit's reader change and the repin that follows it re-record them.

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

* test(mobile): record main's reaction rendering for both providers

No scenario in the corpus carried a comment reaction, so nothing in the 758
goldens could see what a reader does to one. Round-1 review found the
consequence: this branch closed `reactions[].content` to a mobile vocabulary
(`thumbs_up`) that no producer sends, and the corpus stayed green because the
member was never on the wire.

Two scenarios, one per provider, each a second scenario in an existing family so
no matrix base and no existing golden moves. The GitHub one carries
`GitHubReactionContent` as the host sends it — `'+1'` and `'heart'`
(src/shared/github/comment-types.ts:3-17, normalised from GraphQL at
src/main/github/comment-reactions.ts:19-27). The GitLab one carries
`GitLabReaction`, which is `{ name, count }` with no `content` at all
(src/shared/gitlab-types.ts:60-72).

Recorded against main's product tree at `4b876758d3` through the detached-pin
worktree, so the `normal` partition now pins what main renders rather than what
this branch renders. Control: all 758 existing goldens reproduce the previous
pin recording byte for byte; the only difference is these two added files.

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

* fix(mobile): forward a comment reaction instead of matching mobile's vocabulary

`DETAIL_REACTION_CONTENT` was `thumbs_up | thumbs_down | ...`, a vocabulary no
producer of this list sends. GitHub answers `github.workItemDetails` with
`PRComment[]` whose reactions are `GitHubReactionContent` — `'+1'`, `'-1'`,
`laugh`, ... (src/shared/github/comment-types.ts:3-17), normalised from GraphQL
at src/main/github/comment-reactions.ts:19-27 — and GitLab answers with
`GitLabReaction`, `{ name, count }` with no `content` member
(src/shared/gitlab-types.ts:60-72). The closed arm set dropped every real
reaction row on both providers, which is a good-reply path this PR must not
change.

`content` is forwarded now, salvaged the way every other guarded member here is;
`count` stays required, because the `count > 0` filter at
mobile-tasks-item-comments.tsx:145 is the one unguarded read. `DetailComment`'s
eight phantom arms go with it: mobile's declared type was written from memory,
not from the wire, and widening the type is the fix rather than narrowing what
the host may send.

`COMMENT_REACTION_EMOJI` was keyed by that same phantom vocabulary, so it
resolves no glyph for a real reaction and the chip renders without one. That is
a pre-existing defect and it stays exactly as it is: the map is typed
`Record<string, string>` and the lookup takes `?? ''`, which resolves to the
same `undefined` main resolved for both providers. The two scenarios recorded in
68a3db2a3a pin that rendering, so a future arm set cannot drop the rows unseen.

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

* docs(mobile): stop citing the file-contents fiction as the reason nothing is required

The schema comment and its unit pin both named `{ oldContent, newContent,
truncated }` as "the recorded reply", which d26aeecdb0 corrected. The rule that
keeps every member optional is unchanged and is now stated from the reads
instead: the call site files the payload under the file path and reads nothing
off it, the review panels reach each flag through `?.`, and `splitContentLines`
takes `string | undefined` behind a falsy guard. The pins move to the host's own
shape, plus the too-large pair a skipped side carries and the empty object that
shows no member is required.

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

* refactor(mobile): import MERGEABLE_STATE instead of redeclaring it

`task-item-detail-reply-schema.ts` declared its own copy of `['MERGEABLE',
'CONFLICTING', 'UNKNOWN']` while already importing four member helpers from
`../session/github-pr-entity-reply-schema`, which exports that arm set and uses
it in the identical expression three times. Two copies of one wire arm set is
one place to fix when a provider gains a fourth state.

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

* refactor(mobile): inline the alias-only bindings the deleted casts left behind

Each of the eight was `const result = x as { ok?: boolean; error?: string }`.
With the cast gone the line is a rename of a binding that already has a name,
and every one of them is followed immediately by the same `ok === false` check.
Reading `created.ok` / `updated.ok` / `written.ok` / `replyResult.ok` directly
leaves one name per value.

The parity constants move with it and with the reaction change before it. The
comment there names both: ten string literals leave `semantics` with the phantom
reaction vocabulary and one arrives with the `?? ''` fallback, and the alias
deletions move the hook and statement hashes. No `rpc:` or `jsx:` signature
moves, the render-token hash does not move, and the hook, statement and
declaration counts are unchanged at 350, 417 and 194.

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

* test(mobile): register the container-requirement mutant the matrix kills

`f1b695f161` proved by hand that making `linearAccountStatusSchema` `.nullable()`
fails the `result-null` partition of `tasks.provider-load`'s matrix, and left it
unregistered. Registering it in `pilot-mutants.test.ts` is not available: that
suite drives the manifest scenario as written, and `tk-provider-load` scripts a
fulfilled `linear.status`, which a nullable container accepts exactly as the
required one does. The mutation only has somewhere to diverge under a partition
the pilot never reaches.

`family-mutants.test.ts` drives one named variant of a family's matrix instead,
against that variant's own slice of the golden, and each entry names both the
partition that kills the mutation and one that cannot see it — so the entry
records where the coverage is rather than that some golden went red. The
comparison is the whole recorded variant, the way the family suite compares:
under a last-state projection this mutant survives, because the divergence is in
the settlement and both paths reach the same final screen state.

Nothing under `mutants/` is pinned by a golden header, so this moves no
recording.

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

* test(mobile): repin the corpus after the round-1 review fixes

`baseline` moves to 542c1c38ed, the last fenced commit, and the whole corpus is
re-recorded against it. The two fixture corrections and the two added reaction
scenarios were recorded at main's pin first, so this run is what carries them
onto the branch's own reader.

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

* refactor(mobile): keep the Linear create arm's own binding out of the inlining

The alias inlining swept one line further than the finding it answers: the
Linear arm's `const result = linearIssueCreate.interpret(reply)` is a
declaration with a name, not an alias for one, and renaming it put a
pre-existing `createLinearTask(...) as Extract<TaskItem, …>` assertion from
#17438 inside this branch's changed lines, where the changed-code casting gate
attributes it to this PR. Reverted to `result`; the eight bindings the review
listed stay inlined.

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

* refactor(mobile): let each module build its own Linear team reader

`linearTeamListReader` was exported from the detail-operations module only so the
list-operations module could import it, which adds an operations-to-operations
import edge that buys nothing: `rpcResultVariant` is a pure factory, so two calls
with the same schema produce two functionally identical readers. What keeps the
composer's picker and the saved-selection reconciler agreeing about a team row is
that both build from `linearTeamsSchema`, which is already exported.

Deleting the export also puts the composer-policy JSDoc back on
`linearComposerTeamListRead`. JSDoc binds to the next declaration, so the block
explaining why this method carries two operations with different acceptance
policies had drifted onto the reader, leaving the operation it is about
undocumented.

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

* test(mobile): repin the corpus to the last round-1 commit

`baseline` moves to 75c568c244 and all 760 goldens are re-recorded against it.
Nothing but the `baseline` header moves: the two product commits since the
previous repin — the Linear create arm's binding restored and the team reader
built per module — change no observation in any recording, which is what a pure
refactor should look like here.

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

* chore(mobile): repin the RPC recording corpus to the main merge

The merge of origin/main moved both lockfiles, which the recorder fences,
so --record refused until the pin named a commit whose fenced tree matches
this one. Repinned to the merge commit and re-recorded: 760 goldens, header
only, `baseline` and `lockfileSha256`. No observation moved.

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

* docs(mobile): point the reply-schema citations at the lines they name

The alias inlining and the detail-loader rewrite moved fifteen of the consumer
lines these comments cite, and the comments are the evidence for requiring a
member only where the consumer reads it unguarded. Every citation re-checked by
opening it; the host-side ones were already right.

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

* docs(mobile): stop repeating the file-contents fiction on the type

The corrected fixture carries four of the six members at both call sites, so
"the recorded reply carries none of these" is false. The reason is the one the
schema already gives: no reader reaches a member without `?.` or
splitContentLines' falsy guard.

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

* fix(mobile): forward a file's viewed state instead of closing it

No scenario reply carries a file row, so no golden can observe either of this
schema's two arm sets. `viewerViewedState` is read only by two `=== 'VIEWED'`
tests, so it is forwarded and an arm this build predates reaches them as itself.

`status` stays closed: its only consumer sends it straight back as a
`github.prFileContents` param, which the host validates against the same seven
arms (github-pull-request-params.ts:62). Forwarding could not reach the wire
without a cast, and the host would refuse the arm on its own params, so the
drop to absent that becomes `?? 'modified'` is the compatible read.

Parity: one declaration and three arm literals, no executable change.

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

* docs(mobile): say that a Linear state-update refusal is ignored, as on main

`require-result-or-throw-message` throws only on an outer refusal, and
`linear.updateIssue` refuses in band as `{ ok: false, error }` on a
successful envelope, so the refusal reaches no `catch`. Main read the
same payload unchecked and discarded it, so nothing here changes.

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

* test(mobile): repin the RPC recording corpus to the origin/main merge

Main's #20069 re-recorded ten session create-terminal goldens and moved
twenty-one files under src/shared, both inside the recorder's fence, so
`--record` refused until the pin caught up. The re-record moves one key,
`baseline`, in all 760 goldens and nothing else; the ten taken from main
reproduce byte-for-byte apart from that pin.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 02:17:25 -04:00
Brennan Benson 4b87bc718e refactor(agent-launch): redefine the agent.launch contract (#20999)
* refactor(agent-launch): redefine the agent.launch contract

`agent.launch` has no clients yet, so the contract is redefined in place
rather than versioned.

- params require `operation.id`, pinned to the shipped operation-id mint so
  the host can read the embedded timestamp back. No caller-supplied
  fingerprint: the host derives its own.
- the result carries `disposition` ('created' | 'replayed', the same
  vocabulary `RuntimeCreateAgentSessionResult` already uses) and a single
  top-level `warning` instead of one on the terminal arm only.
- the prompt receipt becomes an outcome enum, so a receipt can under-claim
  instead of reporting a bare `delivered: false`.
- the dead `customization` field is deleted, and the mode-reason union and
  receipt are declared once in shared with main re-exporting.
- `clientMutationId` joins the reserved create fields, with a test pinning
  the list to the create schema in both directions.

Contract only; no behaviour change and no ledger wiring.

* docs(agent-launch): stop calling the stripped set "agent fields"

`clientMutationId` joined AGENT_LAUNCH_RESERVED_CREATE_FIELDS, so three
comments describing the stripped set as agent fields now teach the wrong
model — including a SAFETY rationale, where a reader is trusting it most.
The rationale's claim is unchanged and still sound: deleting keys from a
parsed object leaves the rest the parsed shape.

* refactor(agent-launch): make the attempt id the launch's only idempotency key

Review follow-ups on the contract redefinition.

`operation: { id }` becomes a flat `clientOperationId`, spelled the way
`terminal.createAgentSession` and the structured mutation envelope already
spell the same concept, and admitted by the shipped
`parseAgentSessionOperationTimestamp` rather than a second copy of its
pattern — so `agent-session-host-authority` keeps the regex private.

The handler now dedupes on that id instead of the create payload's
`clientMutationId`. That field is optional, so keying on it left any launch
that omitted one with no idempotency at all, while the required attempt id
did nothing. Reserving `clientMutationId` is still right, but for the reason
the comments now give: `createManagedWorktree` never reads it, so a copy left
in the forwarded payload is inert while still reading as a guarantee. The
previous rationale — that it was a second live dedupe key — was not true.

`messageId` moves onto the prompt receipt's `journaled` arm so a producer
cannot report the text as committed without saying where, and `rpcCallerKey`
picks up the `terminal.create` call site it was lifted from instead of
shipping with no callers.

* docs(agent-launch): record why disposition is two-valued only for now

The ledger admits attempts whose outcome was never recorded, and neither
`created` nor `replayed` can say "I cannot tell you" — a caller handed
`created` for an unresolved attempt starts a second agent. Noted at the type
rather than in review, so whoever wires the ledger reads it where they edit.

* fix(agent-launch): keep contract within implemented guarantees
2026-09-16 16:39:38 -07:00
Gon SongandNeil 85d1ffc072 fix: accept enterprise managed GitHub owner logins (#20450)
Unify owner validation across project pickers and repository overrides. Preserve EMU usernames in API and auth-status branch-prefix resolution, with regression coverage.

Co-authored-by: Neil <neil@stably.ai>
2026-09-16 14:41:15 -07:00
Brennan BensonandMerge Sim 97aa5ff19b fix(mobile): open native chat when a new worktree launches a default agent (#19850)
* refactor(agent-launch): make the launch-mode decision surface-neutral

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(mobile): route workspace creates through agent.launch

Picking an agent on the mobile create sheet always produced a terminal, even
when the user's default was native chat, because all three create paths put
`startupAgent` on `worktree.create`. That means "create the worktree
agent-first", so its startup terminal IS the agent and the structured branch
below it is unreachable — while the same phone's in-workspace "+" button opened
a chat.

The blank, branch and new-branch creates now send the same payload through
`agent.launch` and let the host settle the surface. `worktree.create` is
untouched, and a host that does not advertise `agent.launch.v1` (read from the
existing `status.get` probe) keeps today's path exactly.

Work-item creates stay on `worktree.create`: they pre-fill the issue/PR URL as
an unsent `startupDraft`, which a structured session cannot hold yet, so routing
them would submit the URL as a first turn.

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

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

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

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

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

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

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

* test(mobile): supply the agent-launch fixture the create-submit recording needs

The golden RPC recordings landed upstream while this branch was out, so they
first met agent.launch here. Three things had to happen, and only one of them is
a fixture bump.

1. workspace-settings-mounts.ts mounts useNewWorkspaceCreateSubmit against a
   fixture model that throws on any member it was not given. This PR added a
   required getAgentLaunchSupport, so the submit aborted with "Missing model
   fixture" before it ever issued the create, and three cleanup checkpoints
   vanished. That read like a product regression and was not one. Supplying the
   member restores the recording byte-for-byte; it is pinned false for the same
   reason the cutover probe is, so the baseline stays on worktree.create.

2. Editing that adapter moves adapterSha256 for the twelve settings goldens it
   mounts. Their recordings are unchanged - header only, by design: the digest
   is per-golden so editing a module fails exactly the goldens that mounted it.

3. Five goldens changed behaviourally, and both changes are this PR's:
   the capability probe now reports agentLaunch, and a create whose reply
   carries no worktree returns "Failed to create workspace" instead of throwing
   a TypeError off an unguarded result.worktree read. The launch route needs
   that guard, since a receipt can arrive without a worktreeId.

* refactor(mobile): decode the launch receipt instead of asserting its shape

The changed-code quality gate refuses type assertions, and the eight it flagged
were worth removing rather than suppressing.

The production one was the point. readAgentLaunchCreateOutcome asserted the RPC
payload into Partial<AgentLaunchResult> and then runtime-checked it anyway, so
the assertion bought nothing and claimed a contract the host had not proven. It
now narrows with `in` and validates each hop, which is the same nullability
question readCreateResult already answers on the sibling path - a launch receipt
can legitimately arrive without a worktreeId. AgentLaunchCreateOutcome ties
worktreeId to the shared contract so a change there fails this reader's
typecheck rather than passing a differently-typed field through.

The test fakes claimed a whole RpcClient via `as unknown as RpcClient` while
implementing one member. They now build a typed literal, matching the pattern in
use-mobile-structured-agent-options.test.ts. The read sites cast params and then
read one field; they now assert the payload with toMatchObject, which removes
the cast and pins more of the shape than the cast did.

Also pins the warning passthrough, which nothing covered: a terminal launch that
seats the workspace but cannot start the pty reports why, and the absent, blank,
non-string and structured-surface cases report nothing. Writing that test caught
a real drop I had introduced in the reader.

* ci(mobile): re-run Mobile Checks when a shared capability changes

Mobile Checks is path-filtered to mobile/**, but mobile imports the negotiated
capability names straight from src/shared/protocol-version.ts and records the
whole capability read verbatim in its goldens. So a capability added desktop-side
rewrites a mobile fixture while never triggering the suite that would catch it.

That is what happened here: #19849 introduced agent.launch.v1 and Mobile Checks
never ran on it. Verified at the run level rather than by check name - the
window-free check-runs API on 3837ae8d51 returns 49 check-runs across six runs
(PR Checks x2, PR test LoC x2, Track Community PRs, Review) and no Mobile Checks
among them. The breakage surfaced only in this PR, which happens to touch mobile/**.

The workflow already concedes this pattern for terminal-file-link-conformance.ts;
protocol-version.ts has the stronger claim, since mobile records its output.

Also corrects the mount adapter's SAFETY comment. It claimed the recorder supplies
only the members the hook reads, which was false the moment the hook gained a
required getAgentLaunchSupport - and the assertion it annotates is exactly what
stopped the compiler from saying so. The twelve goldens are adapterSha256 churn
from that comment: every body is byte-identical, which is the digest doing its job.

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

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

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

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

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

* refactor(mobile): parse the launch outcome into a named type at its boundary

anti-slop/no-object-parameters flagged terminalLaunchWarning's `result: object`.
The rule is pointing at a real seam rather than a style nit: the helper advertised
a loose object and did the narrowing inside itself, so every caller handed it
unparsed wire data and nothing downstream held a real type.

Parsed at the boundary instead. parseTerminalLaunchOutcome takes `unknown` and
returns TerminalLaunchOutcome | null, so the narrowing happens once, where the
untrusted payload enters, and the consumer works with a named type.

The type is taken from the shared contract rather than restated - a Pick over the
terminal member of AgentLaunchOutcome - so a change to that union fails here
instead of flowing through. `handle` is deliberately excluded: nothing reads it,
and requiring it would drop the warning off a reply that omitted one, which is a
behaviour change smuggled in under a typing change.

No assertion and no config exemption: reintroducing `as Partial<AgentLaunchResult>`
would trade this finding for the defect removed earlier in this branch, and the
rule is correct here.

The rule arrived with the merge-forward (#20781, newer than this branch's
merge-base), and anti-slop is not one of the changed-code gate's six scans - it
runs only repo-wide - which is why a clean local gate did not predict it.

Behaviour is unchanged across all five warning cases, and the positive case was
re-ablated on the new parser: dropping the warning reddens exactly it,
1 failed | 18 passed, restored byte-identical to 19 passed.

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

* fix(agent-launch): memoize the whole launch so a replay cannot mint a second session

A replayed agent.launch could create a second structured session in the same
worktree, with activate: true.

dedupeWorktreeCreate wrapped only the worktree half, inside the workspace
factory. On a replay the create was reused, and the executor then continued to
createSurface and built another surface inside it. The terminal route hid this:
its cached create carries a startup terminal handle, so the executor returns on
early. A structured create has no handle by construction - that is the whole
point of the structured fork - so it fell through every time. Mobile replays
this method deliberately on a delivery-ambiguous response, up to five attempts,
so the path is reachable by design rather than in theory.

The handler now wraps the entire launch in the same dedupe, on the same
(repo, clientMutationId) identity, exactly as worktree.create wraps its own
body. A replay returns the original AgentLaunchResult instead of re-running
createSurface, which makes the two routes replay-identical.

The inner dedupe is removed rather than kept. Wrapping both levels on one key
deadlocks: dedupeWorktreeCreate stores the in-flight promise before the inner
call runs, so the inner call would be handed the outer's promise, which is
waiting on it. The launch-level memo subsumes the worktree-level one.

Failures are still dropped rather than cached, so an unknown outcome stays
unknown instead of replaying as a fabricated success.

The guard replays a STRUCTURED launch: the terminal route cannot reproduce this
and a test there would pass either way. Ablated against the pre-fix files -
1 failed | 22 passed, "expected vi.fn() to be called 1 times, but got 2 times",
which is the duplicate session - then restored to 23 passed. The stub's dedupe
had to be made faithful for that to be observable; the shared one passes through
so other tests can see raw calls.

* Revert "fix(agent-launch): memoize the whole launch so a replay cannot mint a second session"

This reverts commit 59bc5e9b04.

The same defect was already fixed upstream on this stack's base branch by
539e283c0f, which landed while this was being written. That change is broader
(it also cancels the setup wait) and namespaces the dedupe key, so it supersedes
this one. Reverting rather than hand-merging keeps a single implementation
instead of a hybrid nobody chose.

The behavioural guard from this commit is ported back on top of the upstream
implementation separately: it asserts exactly one structured session survives a
replay, where the upstream tests assert the dedupe wiring.

* ci(mobile): close the round-1 signal gaps around agent.launch

Three review findings, all narrow.

Mobile Checks is path-filtered, and this branch made mobile's types depend on the
shared RPC contract: rpc-params-contract.ts is a type-only re-export of the
generated params catalog, and mobile/tsconfig.json includes **/*.ts. So a
desktop-only edit under src/shared/rpc-contract/ could break mobile's typecheck
with no mobile signal at all - the same blind spot the protocol-version.ts entry
closed, one directory over. Added src/shared/rpc-contract/** to the paths filter.

agent.launch had no cross-version trigger. Added the three prefixes a paired peer
actually exchanges: the intent contract, the wire schema, and the RPC method.
src/main/agent-launch/ is deliberately NOT listed - the executor shapes behaviour
but is not itself wire, and AgentLaunchResult's shape is already covered by
agent-launch-intent. Extending the cross-version SUITE to cover a negotiated
handshake is separate work, not this.

The break branch that answers an accepted-but-empty reply with "Failed to create
workspace" had no unit coverage; the golden that used to discriminate it
collapsed five partitions into one shared error when the null guard replaced the
unchecked read. Covered on BOTH routes - worktree.create with no worktree.id and
agent.launch with no worktreeId - since the branch serves both. Ablated by
bypassing the guard: 2 failed | 11 passed, the two new cases returning a
fabricated worktree instead of the error, restored to 13 passed.

* fix(agent-launch): give a launch one place to say the workspace is incomplete

createManagedWorktree reports an unspawned startup terminal or an uncopied
working tree as a top-level `warning`, and worktree.create hands it straight to
mobile. The launch path narrowed that result down to
{worktreeId, startupTerminalHandle} and dropped it, so every agent.launch create
lost a warning the old method surfaces - on both arms.

The channel was also asymmetric by accident rather than design: a terminal
outcome could carry `warning`, a structured one had nowhere to put it, so the
arm this PR exists to enable was the arm that could not report an incomplete
create at all.

Now there is exactly one place a launch warning lives: AgentLaunchResult.warning,
at the top level. It is about the create as often as the surface, it applies to a
structured session and a terminal alike, and a reader should not branch on
outcome.kind to discover the workspace it just opened is missing something. The
terminal arm's own `warning?` is removed rather than left beside it - two homes
for one fact is how they drift. Every producer folds in: the create, the surface,
and the refusal downgrade.

Consumer census before removing it: one production reader (mobile's
readAgentLaunchCreateOutcome) and no others - the renderer and mobile launch
call sites never read it. The mobile reader now reads the top-level field, which
also lets its outcome parser go away entirely.

Guard ablated by restoring the pre-fix narrowing: 2 failed | 24 passed, both
carriers reporting `expected undefined`, which is the dropped warning itself;
restored to 26 passed. The third case asserts an absence and stays green under
the mutation by construction - it pins shape, not the defect.

* fix(agent-launch): combine both launch warnings instead of dropping one

Round 2 found the comment here was false. A create warning and a surface warning
CAN both be set, on two reachable paths:

  1. The create warns precisely BECAUSE it produced no startup terminal -
     didSpawnStartup stays false when that spawn throws, and
     orca-runtime-create-managed-worktree.ts:283 gates startupTerminal on it - so
     the executor's early return is skipped and a second surface is built, which
     can warn too.
  2. An untracked-copy warning, then a definitive structured refusal downgrading
     to a terminal that also warns.

`??` kept the first and lost the second with nothing saying so. They are now
combined the way the create combines its own failures - appendFailure in
runtime-local-worktree-terminal-startup.ts, and the startup-terminal catch in
runtime-remote-managed-worktree-create.ts - which append rather than replace.

The comment is rewritten to say what is true, and records the gap NOT fixed
here: a create warning about a failed startup terminal is stale once the launch
recovers by building a working one, so a user can be told the agent did not start
while looking at it. Distinguishing those needs createManagedWorktree to stop
multiplexing two unrelated failures into one string.

Guarded and ablated: restoring `??` reddens exactly the new test, with the
surface clause missing from the received string; restored to 27 passed. The
structured-create stub had to admit its real ok-or-refusal union for the
downgrade path to be modellable at all - it previously declared only the ok arm.

Also: mobile.yml gains src/shared/agent-launch-intent.ts. It is the sole holder
of the agent.launch RESULT shape - the rpc-contract catalog holds params only -
and mobile imports it as a value. CROSS_VERSION_WIRE_PREFIXES already treats it
as wire-critical; without this, one gate does and the other cannot see it.

And the agent-first warning test no longer pairs "startup terminal failed" with a
returned handle, a combination the producer cannot emit.

* fix(mobile): read a launch warning an older host nests on the outcome

agent.launch moved `warning` from the terminal outcome to the top level of the
result. That is the right shape - a reader should not branch on `outcome.kind`
to learn the workspace it just opened is incomplete - but on the wire it is a
REMOVAL, and mobile only read the new place.

A host built before the move still advertises the same `agent.launch.v1`
capability, so the capability probe cannot tell the two apart and mobile takes
this route against one:

  protocol-version.ts:360       AGENT_LAUNCH_RUNTIME_CAPABILITY is in
                                RUNTIME_CAPABILITIES, the host list
  orca-runtime-get-status.ts:64 publishes it via status.get; the filter drops
                                only browser.screencast.v1 and three E2E-gated
                                capabilities, never agent.launch
  agent-launch-executor.ts      such a host writes warning INSIDE outcome

The result was a regression rather than a contract cleanup: the worktree.create
path this replaces returned the warning at the top level and mobile read it, so
a create that seated the workspace but could not start the agent surface - pty
exhaustion, untracked files not copied - stopped explaining itself on the phone.

Read both shapes for as long as such a host can be paired. Top level wins, and
cannot be shadowed: AgentLaunchOutcome has no `warning` on either arm, so a
current host cannot nest one.

The test that pinned the old behaviour is inverted here. Its comment was the
actual defect - it framed a legitimate warning from an older peer as a stale
shape to defend against, which is what made dropping it look deliberate.

* chore(mobile): raise the unchecked-reader ceiling for the agent.launch receipt

main landed `unchecked-rpc-reader-inventory.ts`, a ratchet on RpcOperation
readers that re-type their reply instead of validating it. Its ceiling for
mobile-workspace-create-operations.ts is 4, counted on a tree without this
branch's `agentLaunchRun`, so the merge produced "listed 4, found 5".

The inventory's own header prescribes this case: a merge is the one time a line
goes up without a migration undoing itself, and the instruction is to raise it
and name the PR that brought it. It describes main landing an operation the
branch never saw; here it is the mirror - the branch holds one main had not
seen - so the line is annotated with #19850 rather than left bare.

Not converted to `rpcResultVariant(variant, schema)`, which would lower the line
instead. That is a validation change rather than a migration, which is exactly
what the file's own comment says these five readers deliberately are not; the
agent.launch reply is already guarded at the consumer, where
readAgentLaunchCreateOutcome returns null on a malformed payload and the create
surfaces "Failed to create workspace". Writing a schema now would also target a
reply shape #20999 is actively redefining.

Ablated: with the line back at 4 the ratchet fails "listed 4, found 5"; at 5 it
passes.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-16 13:15:20 -07:00
Jinwoo Hong b8d4cde09f refactor(mobile): send six screen-mounted call sites through typed RpcOperations (step 4, wave 3) (#20919)
* test(mobile): record six screen-mounted call sites before migrating them

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

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

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

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

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

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

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

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

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

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

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

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

Reused rather than redefined:

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

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

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

Two behaviours are preserved rather than repaired, both recorded:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 22:10:22 -04:00
Jinwoo Hong 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 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 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
Jinwoo Hong fc525c355d refactor(mobile): send the task workspace-creation domain through typed RpcOperations (#20568)
* test(mobile): record main's task workspace-creation RPC behaviour before migrating it

28 scenarios over nine task senders, recorded from main so the step-4 migration of
the workspace-creation half of src/tasks/ has a frozen answer to compare against.
Four senders mount as plain exported functions; three are model-chained hooks
mounted the way the settings adapters mount theirs.

The 153 existing goldens change header-only (`baseline`, `recorderSha256`): any new
scenario re-digests the recorder, and the pinned baseline had drifted from main
because the source-control migration landed. Content is byte-identical on all 153 —
verified field-by-field against HEAD.

`operation-module-loader.ts` now shares src/transport/rpc-delivery-ambiguity.ts with
mounted modules instead of evaluating a second copy. The mark is a WeakSet keyed on
the rejection object, so the copy the loader built had an empty registry and every
delivery-unknown rejection read as a definite failure inside the operation under
test — worktree.create's whole replay path was unreachable. With one registry,
`tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms, while the unstamped-create
scenario records the same rejection surfacing at 0 ms. No existing golden moves:
no other mounted module consumes the mark.

`task-preferences-optimistic` is re-anchored above the send rather than across it,
so migrating this file does not have to move the anchor. It still kills, and for
the same reason: the preset the screen shows no longer follows the tap.

Scenarios deliberately pin the empty-message refusals (`*-refused-empty-message`,
`*-empty-message`), because a refusal with no message falls back to the screen's
copy while a transport error with no message does not, and the two paths are easy
to collapse when a call site moves behind an acceptance policy.

Goldens: 153 -> 201, 2.9M -> 3.7M.

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

* test(mobile): format the recording manifest and re-digest the goldens

`oxfmt --check` from mobile/ collapses a one-element `sites` array in each new
scenario. The JSON value is unchanged — verified by comparing both files parsed
and key-sorted — but the manifest is inside `recorderSha256`, so all 201 goldens
carry a new digest. Every other field, header and observation alike, is
byte-identical.

Re-recorded in a separate worktree at the previous commit so the goldens stay
attributable to main's product source rather than to the migration that follows.

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

* test(mobile): separate the goldens from the migration, and re-digest

The previous commit accidentally carried the product migration alongside the
manifest format, which both broke the commit that is supposed to prove parity and
left the suite red: the digest was recorded without a comment move that a lint fix
had made inside the adapter, so all 201 goldens failed their `recorderSha256`
header.

This backs the product half straight out again — the next commit re-applies it
byte-for-byte — and re-records from the pinned baseline in a separate worktree
carrying this branch's recorder, per the procedure in the recording README. Every
field except `recorderSha256` is byte-identical to the previous commit's goldens on
all 201 files, so no observation moved in either direction. The suite is green here
with main's product source, which is what makes the next commit's "no golden
changed" claim mean something.

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

* refactor(mobile): send the task workspace-creation domain through typed RpcOperations

12 of src/tasks/'s 37 raw-port files now send through a declared operation instead of
the raw request port: 36 references to 0, leaving 25 files and 73 references for the
provider item/detail/mutation half. No golden moved — `git show --stat` on this commit
touches nothing under mobile/rpc-foundation/, which is the parity claim.

Twenty-two operations over twenty methods, in four modules named for what they send:
workspace create (create, PR/MR base resolution, create-time capabilities), workspace
source (SSH connect/state, agent detection, repo hooks, sparse presets, ref search),
task runtime (status, ui.get/ui.set, preflight, Linear status, settings.update) and the
Smart picker's provider reads.

Two methods carry two policies each, and both pairs are named. `status.get`: the Tasks
screen cannot hydrate without it and surfaces the host's message, while create-time
capability probing degrades to "no capabilities" and creates anyway — so one throws on
refusal and one skips. `ui.set`: two sites await it, one is fire-and-forget and never
interprets the reply at all. Both pairs share one reader, so no method has two readers.
No new acceptance policy.

worktree.create keeps its delivery-unknown contract. `request` returns the transport
promise itself, so the retry loop catches the object the transport marked; two new tests
assert `toBe(marked)` in one direction and that an unmarked rejection stays unmarked in
the other, because a mark added on the way out would replay a create the host never
received. `tw-create-retry-ambiguous-after-drop` records the create still pending at the
reconnect wait and abandoning at exactly 20000 ms.

Three sites still read the raw refusal envelope before interpreting, because the code or
the message decides the route and no acceptance policy carries either through: the create
retry needs the message for `isRetryableWorktreeCreateConflict`, and the paste lookup
needs `method_not_found` to retire the slug probe host-wide. Both are documented at the
site.

The hydration barrier keeps raw requests inside its `Promise.all`. main's group rejects as
soon as one leg rejects; `startRpcOperation` + `interpretAtRpcBarrier` would wait for the
slowest peer and let a later policy surface a different error. Interpretation stays after
the `stale` guard, where it was.

`WorkspaceCreateParams` is now `RpcSendParams<'worktree.create'>` rather than
`Record<string, unknown>`, which types the builder and the operation together; every field
the three builders already sent typechecks against the host schema unchanged.
`RpcSendArguments` now also makes params optional for a method whose params type has no
required field, because `preflight.check` is such a method and main sent it none —
requiring `{}` would have put a new object on the wire.

The Mobile Tasks source-parity hashes move for the same reason bound settings requests
moved them: the method string and the envelope read leave the screen. The signature diff
is evidence rather than a re-pin — `semantics` is a pure deletion of 22 `rpc:` call
signatures and 22 method literals with nothing added, statement/declaration/render/style
counts are unchanged, and render tokens, styles and declarations are byte-identical.

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

* test(mobile): split the task workspace adapters at the sender/hook seam

The single adapter file reached 344 lines against mobile's 300-line limit. CI lints
every file, so this is red there even though the changed-code gate does not report it.
Split along the seam the recording README already draws: exported async senders that
take a client and need no React host, and the drawer's three model-chained hooks.
No adapter body changed.

Both files are inside `recorderSha256`, so all 201 goldens carry a new digest. Every
other field is byte-identical, verified file by file. Re-recorded from the pinned
baseline in a separate worktree carrying this branch's recorder, so the goldens stay
attributable to main's product source.

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

* refactor(mobile): give the one-key unchecked reader a name

Four readers were the same three lines: read one property off the reply, wrap
it unchecked. `rpcUncheckedMemberReader` is the one-key sibling of the existing
`rpcUncheckedPayloadReader`, so the annotation and the closure go away at each
site. The pilot's `commitCompareEntriesReader` is converted too, so the helper
has no longhand twin left to copy from.

No behaviour change: the helper composes the same `rpcReadUnchecked` over
`rpcPayloadMember`, including the property-read exception on a null result.

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

* test(mobile): record the local arm of workspace agent detection

`preflight.detectAgents` was the one migrated operation with no recorded
coverage: the ssh adapter hardcoded `connectionId: 'ssh-1'`, so the detection
effect's ternary only ever took the remote arm and the local call site could be
repointed at another method without a golden noticing.

The adapter now takes the connectionId as a parameter and registers twice;
`tasks.workspace-ssh-local` mounts the same hook with no connection, which is
the only difference the effect branches on. Recorded at the pinned baseline
with this branch's recorder laid over it, so the new golden is main's
behaviour and the migrated code has to reproduce it — it does.

Goldens: two added (`tw-workspace-ssh-local-agents` and its reply matrix). The
other 201 changed on `recorderSha256` only, because the adapter edit moves the
recorder digest every golden pins.

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

* test(mobile): drive workspace create and the Linear list to a recorded wire

Two operations passed a policy swap unnoticed, both because no golden reached
their acceptance branch.

`worktree.create`: the create hook's fixture resolved setup to a prompt, so all
three settings.task-workspace scenarios stopped before the request and the only
consumer that hands a refusal to interpret was never recorded. The adapter now
takes the setup resolution as a parameter and registers a second family that
resolves it, so createWorkspace runs to the wire. Two scenarios: a Linear item
that creates directly, and a GitHub pull request that resolves its base first,
which also puts this hook's built params — start point, generated display name,
agent launch fields — in a golden for the first time. The existing prompt
family is untouched, so its recordings still pin that branch.

`linear.listIssues`: it appeared only in a non-base scenario, and the matrix
reads the family base, so the family had no partition for it. The base now
lists assigned issues after searching.

Goldens: five added. Five moved beyond the digest, all derived from the
smart-search base that gained the list leg. The other 198 changed on
`recorderSha256` only. Recorded at the pinned baseline with this branch's
recorder laid over it, so every new golden is main's behaviour.

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

* test(mobile): drop the unreachable unmount branches from the task adapters

Nothing dispatches `unmount` to these three adapters: the only producer is
`lifecycleSchedules`, driven from a hardcoded five-id list that names no
task-workspace family, and it pushes a `remount` right after, which these
adapters would throw on. The branch read as lifecycle coverage that was never
wired up. `dispose: hook.unmount` already tears the mount down.

Goldens re-recorded at the pinned baseline because the recorder digest moved;
`recorderSha256` is the only line that changed in all 208.

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

* test(mobile): re-record the goldens at main's post-squash baseline

Recorded from a detached checkout of e53f1557e1 (main's unmigrated
product code) with this branch's recorder laid over it, so the parity
claim stays non-circular.

- `baseline` repinned to e53f1557e1 on all 208 goldens; main pinned
  5ec0b2698f, a pre-squash branch commit not reachable from main.
- `recorderSha256` moved on all 208 because this branch's adapters are
  in the whole-manifest digest.
- 55 task-workspace goldens re-recorded at the new baseline.
- No other line in any of main's 153 goldens changed.

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

* test(mobile): re-record the goldens under #20562's per-scenario digest

Baseline repinned to 50e752fc66 and all 208 goldens recorded from that
commit's unmigrated product tree with this branch's recorder laid over it.
recorderSha256 moves on every golden because the task-workspace adapters
live in the recorder directory. scenarioSha256 does not move on any of
main's 153: the manifest only adds 31 scenarios and edits none, which is
the property #20562 was built to give.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-14 13:36:16 -04:00
Jinwoo Hong b0070e3720 refactor(mobile): migrate settings reads to RpcOperation (#20499)
* refactor(mobile): migrate settings reads to RpcOperation

Replay the settings slice on the landed RPC foundation after rebasing onto main.

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

* test(mobile): refresh task parity snapshots after main rebase

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

* test(mobile): correct rebased declaration parity hash

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

* test(mobile): account for main task declaration

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

* fix(mobile): preserve raw RPC rejection timing

Return the transport promise directly and interpret replies separately so sibling Promise.all rejection order cannot change.

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

* test(mobile): refresh parity hashes after timing fix

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

* fix(mobile): use operation interpreter after raw request

* test(mobile): refresh settings migration parity hashes

Refresh hook and statement parity hashes for the two task declarations whose settings reads now use RpcOperation request and interpretation.

Changed declarations:
- useMobileTasksRuntimeHydration: settings.get replaced by settingsRead request/interpret.
- useMobileTasksWorkspaceCreateActions: settings.get response handling replaced by settingsRead request/interpret.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-13 17:53:58 -04:00
Neil df375cdd8a perf(mobile): reuse Linear issue ordering when grouping list and board (#20314) 2026-09-12 21:19:00 -07:00
Neil 7e9ade7c74 perf(mobile): reuse Linear grouping between list and board (#20431)
* perf(mobile): reuse Linear grouping between list and board

* test(mobile): realign parity oracle and ratchet with current main

Rebasing onto main surfaced two breakages that the earlier ratchet-only fix
could not have caught, because it was computed against a base main had already
superseded:

- The parity oracle called compareLinearIssues, which #20249 deleted in favour
  of sortLinearIssues. Rewrote the oracle to use sortLinearIssues, matching what
  the production memo now calls, and dropped the stale mock override.
- Regenerated EXPECTED_SCREEN_HOOKS and EXPECTED_STATEMENTS from an observed run
  on the rebased tree. Arity assertions (350 hooks, 417 statements) unchanged.

mobile/src/tasks: 37 files, 295 tests pass.
2026-09-12 20:36:07 -07:00
Neil a045af3618 perf(mobile): precompute Linear issue sort keys (#20249) 2026-09-12 18:36:28 -07:00
Neil 701dc2211c perf(mobile): precompute task sort keys and reuse repository collation (#20233) 2026-09-12 18:15:32 -07:00
Brennan BensonandMerge Sim 9a56797486 fix(mobile): surface host create warnings and terminal-create errors (#20125)
* fix(mobile): surface host create warnings and terminal-create errors

A workspace created from the phone could land on "No tabs in this session"
with a bare red "Failed to create terminal" and no way to tell why. Two
independent drops hid the host's own explanation:

- createWorktreeWithNameRetry returned only {worktreeId, name}, discarding
  worktree.create's `warning`, and hostNewWorktreeSessionRoute built the
  session route with only `name` + `created=1`. The session screen has always
  had the banner (MobileSessionContentRow + createWarningState) -- only the
  tasks create path ever fed it, so the New Workspace path could never report
  a startup terminal that failed to spawn.
- handleCreateTerminal collapsed every failure to the literal
  'Failed to create terminal', throwing away response.error.message.

Both now propagate, so the daemon's pty-allocation hint ("Your system cannot
allocate any more pty devices.") reaches the phone instead of dying in the
main process. Behaviour is otherwise unchanged: a blank warning is still
omitted from the route, and a host that gives no reason still reads
'Failed to create terminal'.

* test(mobile): refresh route parity baselines

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 11:21:31 -07:00
Jinwoo Hong 8f15f217a2 Preserve user-set workspace names across branch changes (#17448)
* fix(worktrees): preserve user workspace names across branch changes

* test(worktrees): cover pinned rename metadata

* fix(workspaces): address display-name review edge cases

* fix(workspaces): keep automatic names fresh across refreshes

* fix(workspaces): preserve legacy CLI labels

* fix(workspaces): preserve display-name provenance across hosts

* fix(workspaces): honor legacy display-name provenance

* fix(workspaces): fence display-name refresh races

* fix(workspaces): accept peer renames from provenance-less hosts

The old-host preserve fence kept a pinned local label on every refresh,
which also suppressed a legitimate rename another client persisted
through the same host until app restart. Narrow it to labels the host
re-derived itself (branch short name, or path basename when detached);
any other changed label in a mode-less response is explicit meta a peer
wrote there. Stale prior-label responses stay covered by the downstream
staleness fence, in-flight writes by the pending fence.

* refactor(workspaces): unify display-name pin derivation

Three call sites (renderer optimistic update, local IPC updateMeta
handler, remote worktree.set handler) each restated the same formula;
a future edit to one would silently skew provenance between paths.
2026-08-31 19:08:05 -04:00
a651e81843 refactor(agents): remove dead hook IPC and derive shared agent defaults (#16089)
* refactor(agent-hooks): drop the unused per-agent hook status IPC surface

No renderer, CLI, or mobile caller invoked window.api.agentHooks.*Status; main
already reads install status through MANAGED_AGENT_HOOK_STATUS_READERS. The
14 handlers had also drifted (kimiStatus existed in main/preload but not in
AgentHooksApi or the web stub).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tui-agent-config): default launchCmd and expectedProcess to detectCmd

32 of 36 entries repeated the binary name three times. Entries are now
authored in a source form where both default to detectCmd and resolved once
at module load, so TUI_AGENT_CONFIG keeps its exact shape for consumers
(verified equal to the previous table).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mobile): derive the agent order, labels, and picker from src/shared

The mobile mirror (and its regex-over-desktop-source parity test) predates
mobile importing runtime values from src/shared, which it now does in a dozen
modules. Only the favicon-domain map stays mobile-local because desktop's lives
in the renderer catalog next to bundled ?url imports. The parity test now
imports the real registries and also checks label parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): align preload surface after hook IPC removal

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-30 19:02:14 -07:00
Neil 81b4d88bd6 refactor(mobile): split tasks route into focused modules (#17438)
* refactor(mobile): split tasks route into focused modules

* fix(mobile): repair tasks refactor module boundaries

* chore(mobile): document intentional render resets

* fix(mobile): remove stale lint suppressions from tasks split

* test(mobile): keep parity checks stable with doctor suppressions

* test(mobile): follow tasks module split

* test(mobile): follow project routing module split
2026-08-30 14:10:36 -07:00
Brennan Benson 8cf7c6926e fix(mobile): create-worktree sheet dies after picking a source and loses the picked PR (#16917)
* fix(mobile): keep the create form on screen through drawer swaps and survive reconnects

The create-worktree flow could reach a state where the shared modal host was
mounted with no sheet in it: a full-screen transparent window that swallows
every tap with no way out. Frame analysis of the reported recording and a live
simulator repro both land on the same state - the form sheet laid out at the
right frame with progress=1, backdrop painting, sheet not painted.

- Keep the form sheet mounted through every drawer transition, so the host
  Modal is never on screen without a sheet, and drop the render-read pin ref.
- Re-assert a pinned sheet's enter transform when it takes the window back
  from a fill picker; nothing re-applied it before.
- Key the form session on hostId, not on the RpcClient object: useHostClient
  swaps that object on every reconnect, which silently remounted the form and
  threw away the picked source.
- Run the pasted-item lookup concurrently with the provider fan-out instead of
  after it (measured 2631ms -> 1480ms for a typed PR number).

* fix(mobile): remount the sheet view on window hand-back so a rebuilt native view repaints

On-device confirmation showed the committed hand-back re-assert never
reaches the native view: progress already sits at 1 and translateY at 0,
so withTiming produces no style delta, and the dead screen stayed
reachable (1/25 on the committed build; 1/9 with a sub-pixel value
nudge, which lands on the stale native binding when the view was rebuilt
with a new tag). Remounting the sheet's Animated.View on an epoch keyed
to the hand-back mounts a fresh native view with the style computed from
the current shared values - progress is already 1, so it paints in place
with no visible animation. 0 dead in 50 attempts on the remount build
under the same churn condition that reproduced the dead screen on base.

LANE-REPORT.md carries the full confirmation evidence and limits.

* chore: drop the stray lane report from the repo root

It is a working artifact, not source, and the root directory guard blocks
any new top-level entry.

* test(mobile): assert the sheet subtree rebuild directly, not through a test-only prop

The hand-back test proved the remount by reading an epoch-keyed nativeID that
existed only for it — production markup shaped by a test, and an assertion a
future refactor could satisfy without rebuilding anything. Count mounts of the
sheet's content instead, which is the property the fix actually depends on, and
drop the nativeID.

Also stop typing test renderers as 'ReactTestRenderer | null'. The static
analysis job installs no mobile/node_modules, so that type is unresolvable
there and the union trips no-redundant-type-constituents on every added line.

The hand-back re-assert is not dead code as the old comment implied: the drawer
swap hands back at 166ms, before the 180ms enter animation ends.

* fix(mobile): keep the create form when a render is thrown away

The session key was built from counters mutated during render. A blurred screen
suspends this subtree (react-native-screens freezes via react-freeze), so React
runs the component and then discards that render — but the counter bumps
survive it. The next committed render then produced a new key and remounted the
form, throwing away the picked source for a host switch or a close that never
committed.

Hold the open epoch in state, which React discards with the render that set it,
and put the host in the key directly instead of counting host changes.
2026-08-28 15:45:54 -07:00
Brennan Benson 81ae98e10d fix(mobile): honor host worktree create retention (#16342)
* fix(mobile): honor host worktree create retention

* fix(mobile): cover malformed worktree retention policy

* fix(mobile): fail closed on malformed retention policy

* fix(mobile): fail closed on missing dedupe ttl
2026-08-27 16:02:38 -07:00
Brennan Benson 30bf2647fc fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it (#15472)
* fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it

A socket close or response timeout rejects an in-flight worktree.create as
delivery-unknown: the frame reached the wire, so the host may already have
built the worktree. The client only replayed connection-migration cutovers,
so every other ambiguity surfaced as a create failure for a create that may
well have succeeded. Replay on the same clientMutationId — which the host
already dedupes — after waiting for the transport to come back.

* fix(mobile): bound the ambiguous worktree.create replay by the host's dedupe window

The replay was bounded only by a retry count, but what makes a replay reconcile
instead of building a second worktree is wall clock: the host drops a settled
create's dedupe record 60s after it resolves, and past that the replay is just a
fresh create that the host's suffix loop happily duplicates — for a folder
workspace, into a second workspace with the very same name and no collision
check at all.

Two paths ran past that window:

- The request-timeout path. A silently dropped response frame leaves the socket
  alive, so nothing rejects until WORKTREE_CREATE_TIMEOUT_MS — ten minutes, with
  no bound at all on when the host actually resolved. This was previously the
  path that replayed *soonest*, short-circuiting the reconnect wait because the
  transport still looked healthy. Invert it: every path that reports a real drop
  has already left 'connected' by the time the rejection surfaces, so still being
  'connected' identifies the timeout and is now refused.
- The reported-drop path. Worst-case detection is a full liveness idle period
  plus the missed-probe budget before the client even learns the socket is dead,
  and the old 20s wait on top of that overran the record. Derive the wait from
  the watchdog constants and the TTL instead of hardcoding it, and anchor a
  single deadline at the first ambiguity so a second wait gets the remainder
  rather than restarting.

The TTL now has one definition shared by both processes, so the client asserts
its budget against the host's real window instead of a copied literal.

* fix(mobile): end the reconnect wait on a revoked pairing, and pin the wait's behavior

waitForRpcClientReconnected resolves only on 'connected' or the timeout, but an
'auth-failed' client never reaches 'connected' — so a create interrupted by a
revoked pairing sat out the full wait before surfacing the error it already had.
Treat auth-failed as a terminal answer on both the fast path and the listener.

The helper also shipped with no tests of its own: its already-connected fast path,
its timeout path, and the synchronous-notification-during-subscribe teardown were
only ever exercised indirectly through the retry suite, and neither RpcClient
implementation notifies synchronously, so that branch had no coverage at all. Add
a direct suite covering all of them, asserting listener and timer teardown rather
than just the resolved value.

Also give the fake-timer tests an explicit timeout. advanceTimersByTimeAsync
yields through real macrotasks between ticks while vitest's own budget runs on
real time, so on a loaded runner the default 5s is reachable — observed once as a
spurious timeout in this suite.

* fix(mobile): bound the ambiguous replay in wall clock, not timer time

The replay window was derived from the liveness watchdog's own budget
(idle + missed probes x probe timeout). That is a bound on how long the
watchdog takes to *fire*, not on how much wall clock passed. iOS and
Android suspend JS timers while the app is backgrounded, so across a
background cycle the socket dies silently and the pending create rejects
delivery-unknown minutes later with the timer-derived ceiling still
reading ~44s. The replay then lands well past the host's 60s dedupe
record and the suffix loop builds a SECOND worktree - for a folder
workspace, one with the very same name and no collision check at all.

Anchor the deadline on the watchdog's lastInboundAt instead: a wall-clock
stamp of a frame that really arrived, so it stays honest across a
suspension. Fall back to the send time when the transport can't vouch for
one (relay sessions run with idleProbeMs: null), which errs toward
refusing the replay.

Also restore the delivery-unknown discrimination test that the
still-connected guard had made vacuous, pin the still-connected guard
itself against a live inbound stamp, and pin the deadline against being
re-read from a fresher replacement session.
2026-08-19 18:10:39 -07:00
Brennan Benson ab9d1a29a9 fix(worktree): never reissue a generated workspace name (#14350)
* fix(worktree): never reissue a generated workspace name

Generated workspace names were deduped only against currently-live
worktrees, so deleting a workspace returned its name to the pool. A later
workspace could draw the same name, land on the same directory path, and
inherit the previous occupant's agent conversation history — coding-agent
CLIs key their prompt history and transcripts by cwd.

Names are now retired permanently per repo. The registry is written in
main with the name Git actually used (the create loop can advance past a
requested name on collision), and seeded once per run from workspace
directories and surviving agent transcript buckets so already-spent names
are excluded from the start. Suggestions degrade to -2, -3 variants
instead of recycling, and those variants retire too.

User-typed names are untouched: retirement filters suggestions only.

* fix(mobile): honor retired workspace names, on one shared implementation

Mobile hand-duplicated the desktop name-suggestion algorithm and deduped
only against live workspaces, so a phone could still be offered a name
whose deleted workspace left agent conversation state behind at that path.

Both platforms now call one shared selector in src/shared, so the two can
no longer drift. The host publishes retired names as an optional field on
the existing worktree.list response, and mobile fetches them per selected
repo while the create sheet is open — mirroring the desktop hook.

Mobile never calls worktree.list for its catalog (it uses worktree.ps,
which carries rows only), so this is a targeted request rather than a
change to the catalog or its cache. Hosts predating the field omit it and
mobile falls back to live-only dedupe, which is the pre-change behavior.

* fix(worktree): close retirement consistency gaps

* test(worktree): cover retirement runtime contracts

* fix(worktree): retire generated collision names

* fix(worktree): enforce retired names at creation

* refactor(ai-vault): extract the Claude project-dir encoder

The bucket-name encoder and its scope-boundary check were private to the
session scanner, so a second consumer had to reimplement them — and got the
per-character encoding wrong. Move both to a shared module with direct tests.

* fix(worktree): make the retirement seed scan actually match buckets

The bucket encoder collapsed runs of non-alphanumerics while the real one
emits a dash per character, so every dot-path bucket missed and the Windows
default workspace root (C:\...) matched nothing at all. Reuse the shared
encoder and its boundary check, which also stops a repo absorbing a sibling
whose path merely shares its prefix.

Also:
- Derive the workspace leaf by stripping the known encoded parent instead of
  guessing from trailing dash segments, which retired the parent directory's
  name whenever a workspace was named numerically.
- Reuse isAutoGeneratedCreatureBranchName so the -10 and -100 tiers retire.
- Drop the .codex/sessions root: Codex keeps the cwd inside the transcript
  rather than in a directory name, so the scan could only ever see a year
  folder. Reading transcript contents is not a trade this feature justifies,
  so the gap is documented instead.
- Honor CLAUDE_CONFIG_DIR, which relocates the bucket root.
- Delete the unused retirableLeafName export.

Tests write buckets with the real per-character encoding against a fake home,
covering POSIX, dot-directory, Windows drive and WSL UNC roots; all three
platform cases fail against the previous encoder.

* fix(worktree): retire only generated names, keyed by cwd namespace

Two problems in the host-side registry.

Retirement fired for every create, including names the user typed. The
creature pool contains ordinary words — orca, runner, sole, molly, oscar — so
typing a retired 'nautilus' silently produced directory and branch
'nautilus-2' and burned the name for good. Creates now carry an explicit
nameWasGenerated flag; both the skip and the retire are gated on it, and it
defaults to false so CLI and automation callers are unaffected.

The registry was keyed by repo id, but both readers already discarded the id
and unioned by the cwd collision key, because the collision this prevents is
on the path. Keying by that namespace directly fixes several things at once:
entries no longer orphan when a repo is removed, remove/re-add no longer loses
every retirement for an unchanged path, the missing removeProject prune is
moot, and the backfill promise no longer merges into only the first repo id it
saw. The feature is unreleased, so no migration is needed.

Also:
- Memoize the collision key. It runs computeWorktreePath, which for a WSL repo
  is a blocking execFileSync('wsl.exe') whose failure path is uncached, and
  the previous code recomputed it once per repo on every create and every
  listRetiredNames call.
- Drop retiredNamesByRepo from the worktree list result. It had no readers and
  leaked onto 'orca worktree list --json', and its awaited backfill sat on CLI
  selector resolution. The dedicated listRetiredNames RPC keeps its consumers.
- Make the three RuntimeStore methods required. RuntimeStore is file-private
  with two constructors, so the 'older embedders' the optionality protected do
  not exist, and the optional chain silently returned no retirements.
- Revert the unrelated forceDeleteBranch rewrite, and make room under the
  file's line budget by extracting the create-args mapping instead.

* fix(worktree): send name provenance and stop gating Create on the fetch

Desktop and mobile now mark a create as generated-name only when the user
typed nothing and the composer fell back to the suggestion, so the host knows
which names it may retire.

Remove the retired-names loading gate from every create path. The host already
skips retired candidates before doing any git work, so the client gate bought
nothing while it could disable Create for the length of a full mobile
reconnect ladder (the wait had no timeout) and blank the desktop button
between queued creates. The suggestion still waits; the button never does.

Also make the web client call worktree.listRetiredNames instead of hardcoding
an empty list — the method is registered and mobile-allowlisted, so the
comment claiming no wire call existed was wrong — and filter the mobile
response to strings so a malformed row cannot throw during normalization.

* fix(worktree): key retirement by repo id and prune it with the repo

Reverts the collision-key storage key. It was a function of workspaceDir,
nestWorkspaces, worktreeBasePath and repo.path, so toggling any one of those
orphaned every retirement for every affected repo at once — trading a rare
churn (remove/re-add) for a common one. The read path already unions by cwd
namespace at query time, so cross-repo sharing never depended on the storage
key.

Instead, address the growth and orphaning directly:
- Drop the registry in removeProject, and in removeProjectForHost once the last
  host's copy of the repo id is gone, alongside the sparse-preset deletes that
  already follow this convention.
- Bound each repo's registry. The cap sits far above the 552-name pool because
  evicting inside it would reissue a name whose agent state is still on disk;
  only -2/-3 tier accumulation can ever reach it.
- Carry retirements through profile transfer, re-keyed to the destination repo
  id and dropped from the source, mirroring sparsePresetsByRepo.

Separately, fix the backfill merge: the scan promise is cached per cwd
namespace, but it closed over the first repo id that triggered it, so a second
repo in the same namespace received nothing. The scan stays shared; the merge
moves out of the cached promise and runs for whichever repo asked.

Local repos re-seed on re-add through that backfill. SSH repos do not — the
scan cannot see the execution host — which is now stated in the module.

* docs(worktree): spell out why the retirement bound sits above the pool

Names the trap directly: the neighbouring 50/200 bounds cap histories, so
lowering this one to match them would silently start reissuing names whose
agent state is still on disk. Also states that oldest-first eviction is a
deliberate least-bad choice rather than a neutral one.

* fix(worktree): send name provenance from the web runtime client

This client hand-enumerates worktree.create params, so the new optional field
was silently dropped and typecheck could not see it. On web and paired-desktop
the host therefore never received it: generated names were never retired, and
the host-side skip that backstops a stale suggestion was disabled too. The same
client does fetch retired names for suggestions, so it was filtering against a
registry nothing ever wrote to.

The test asserts both directions, and fails without the fix.

* fix(worktree): retire names that took more than one collision suffix

isAutoGeneratedCreatureBranchName strips exactly one trailing -N, which is
right for auto-rename eligibility but wrong here. Once the pool is spent the
suggester emits nautilus-2, and a collision on that yields nautilus-2-3 —
which a single strip leaves as nautilus-2, not a pool name, so retirement
no-opped at exactly the tier where every base name is already gone. Strip
repeated suffixes locally rather than moving the auto-rename predicate.

* perf(worktree): keep the retirement backfill off the blocking WSL probe

The backfill runs on composer repo-select, not just at create time, and it
derived the probe path synchronously — which for a WSL repo with a mirrored
workspace dir reaches getWslHome and its blocking execFileSync('wsl.exe').
A stopped distro froze the main process for up to 5s on composer open.

Adds an async twin of computeWorktreePath and uses it for the probe. Resolving
the home there also warms the shared cache, so later sync callers are free.

Also stops memoizing the collision key when the WSL home is still unresolved:
only the success path is cached upstream, so caching the fallback namespace
would strand the repo there for the rest of the session.

* fix(worktree): hold retired names across a refresh instead of blanking

refreshKey changes on every workspace-list mutation, so create-multiple
refetches after each create and the hook returned an empty list until the
refetch landed — precisely the window in which resetForNextCreate clears the
name field and a fresh suggestion is drawn. Keep the previous answer while
revalidating and reset only when the repo changes; a failed refresh keeps what
was already loaded rather than un-retiring everything.

Also makes the returned array referentially stable, so the suggestion memo
downstream stops rerunning on every refetch.

* refactor(worktree): put the retired-name cache rules on one implementation

The desktop and mobile hooks that fetch retired names had already drifted
four ways. The transports genuinely differ (IPC vs RPC), but the caching
rules must not, and mobile's copy reset to [] on any error -- which
un-retires every name for the rest of the sheet session, the one outcome
retirement exists to prevent.

Moves the rules into src/shared/worktree/retired-name-cache: response
normalization, the never-leak-across-repos rule, and the hold-previous-on-
failure rule. Pure, no React, because src/shared is on the main process's
import graph. Each platform keeps its own transport and effect.

Mobile moves up to desktop's behavior: it now holds the previous answer
through a failed refresh, and refetches when the workspace list changes
instead of never refetching after mount.

Also drops the unused `loading` return. Neither platform consumed it; its
only consumer was the Create-button gate reviewed out earlier, and removing
it makes that regression unexpressible.

* fix(worktree): import shared types from their real modules

Main dropped the src/shared/types barrel, so the retirement module's import
resolved locally but not against the PR's merge base.

* refactor(worktree): bound the retirement registry by tier compaction, not eviction

Retirement is a correctness guarantee — a spent name's directory may still hold
agent conversation state keyed by that cwd — so the 2000-entry cap was the wrong
shape: reaching it handed a name back. At the owner's measured rate (~6.6 pool
names retired per day in one repo) the cap was ~9 months out.

Names come from a fixed 552-entry pool and the suggester only reaches tier N+1
once every tier-N name is taken, so a completed tier is exactly a set that no
longer needs listing. A row is now a watermark plus the names above it: reads
answer at-or-below the watermark with no lookup, and compaction drops the 552
entries the watermark now covers. Bounded at one pool per repo forever, with no
eviction and nothing un-retired.

Tiers can complete out of order (a create-time collision can spend `nautilus-2`
while tier 1 is open), so compaction loops and higher-tier names simply wait.

The RPC result carries the watermark beside the names as a new field; a client
predating it reads the names only and under-retires the compacted tiers, which
degrades to the pre-retirement behavior rather than breaking.

* fix(worktree): preserve generated name retirement across failures
2026-08-14 22:18:36 -07:00
Brennan Benson 266b5ae8f5 fix(mobile): match desktop project and run target picker (#14457)
* fix(mobile): disambiguate repository locations

* fix(mobile): preserve explicit repository ownership

* test(mobile): use explicit renderer type

* refactor(mobile): match desktop project targets
2026-08-14 10:22:55 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00
Neil 9796f7dc5f refactor(mobile): use shared GitHub Project sorting (#13459) 2026-08-10 20:41:34 -07:00