Commit Graph
169 Commits
Author SHA1 Message Date
Neil ec9f35e2ee perf(ci): plan the unit shards before the static-analysis gate instead of behind it (#23743)
A caller's `needs` gate the whole called workflow, so while the plan job lived in
unit-tests.yml it could not start until static analysis and typecheck had both
finished and passed -- and the shard matrix then waited on it. The two hops were
serial when they did not need to be: planning reads the checkout, a git diff
against HEAD^1, the import graph and the checked-in timing baseline in
config/scripts/ci-shard-timings.json, and consumes nothing that static analysis,
typecheck or the native-cache primer produce.

Planning moves to its own reusable workflow so pr.yml can run it against
code_paths alone, overlapping it with the gate. Measured across 99 runs, the
shard matrix is created a median 93s earlier (p25 47s, p90 241s, never later).
Planning stays a required predecessor of the shards, so an empty assignment
cannot expand the matrix.

The gate itself is deliberately left in place. It fires on 22% of runs, and the
shard queue wait knees hard above ~9 concurrent ARM jobs -- 4s median below that
against 218s at 15-19 -- so admitting 8 doomed shards per failed run would cost
more in queue pressure than it returns in latency.

Cost is one 37s ubuntu-latest job, which does not touch the ARM pool the shards
contend for.

A planning failure still fails the PR: the shards are skipped, and verify's
check_job requires success whenever the classifier says tests should run, so it
reports `test: expected success, got skipped`.
2026-09-28 19:22:10 -07:00
Brennan Benson 2ca4ecbc61 feat(orchestration): let a structured chat run orchestration as itself (#22568)
* feat(orchestration): inject the Orca session id into structured children and let the CLI act as it

Every structured session's child (native Claude, native Codex, and the terminal
view) carries ORCA_AGENT_SESSION_ID and reaches the Orca CLI. The CLI sends the id
in the orchestration envelope; when present it is the caller, and a caller flag
naming anyone else is refused before any request. The id is stripped from
inherited PTY env and from the SSH host-CLI passthrough, and crosses into WSL so
the host can refuse the cross-host claim.

* test(orchestration): pin session id injection for native Claude, native Codex, the terminal view, WSL, PTY inheritance and SSH

* test(orchestration): pin one caller precedence rule across every CLI verb that names its caller

Adds the per-verb table (flagless acts as the session; a conflicting --from or
--terminal is refused before any request; the session's own spellings are
accepted), the enumerated guess population with its positive control, the
structured worker's own handle, the identity-less refusal for an older child,
the unchanged terminal agent, and the envelope. dispatch-show's --from only fills
preview text, so it passes through unfenced and a session's flagless preview
names the address the real dispatch writes.

* refactor(orchestration): keep the identity-less marker reader to the marker; the id is checked first

* test(orchestration): pin that a host refusal of the session surfaces verbatim from the CLI

* fix(orchestration): keep the identity-less marker beside the id for CLIs that predate it

A CLI older than the id, reached through a global install when a shell rc resets
PATH, would otherwise guess a sibling's terminal in a chat that no longer carries
the marker. It refuses on the marker instead; a current CLI checks the id first,
so the marker never makes a session with an id identity-less.

* fix(orchestration): refuse a conflicting --from on gate-list and task-list scoped by --run

A --run listing needs no caller, so both handlers skipped the resolver and a
--from naming another actor was dropped silently under a session. The conflict
check now runs on that branch too; terminal callers are unchanged.

* fix(orchestration): name this app's CLI by absolute path for a structured session's login shells

A provider can run each command in a login shell: Codex runs zsh -lc, and the
profile rebuilds PATH, putting a global install (possibly an older Orca) ahead of
the directory Orca prepended. ORCA_CLI_COMMAND, which an agent resolves the CLI
from first, is now the absolute launcher in that directory (the native launcher
on Windows), so no shell's startup files can swap it. The PATH prepend stays for
shells that read no profile. Found by the live coordinator run of the next PR.

* test(orchestration): pin a structured worker's CLI command as this app's absolute launcher

* test(orchestration): run the zsh login-shell arm in the real-shell lane that installs zsh

The ordinary Linux unit lane has no /bin/zsh, so the zsh arm failed there with
ENOENT. It moves to a live-shell file registered in the shell-contracts lane; the
bash arm keeps running in every lane. The lane guard's detector now also sees a
zsh spawned through the ProcessSpec program field, which is how this test
escaped it.

* fix(orchestration): omit a structured child's CLI command when no launcher resolves, and pin its instance

A bare `orca` fallback named GNOME's screen reader on packaged Linux, and an inherited value named
another app's CLI. The builder now deletes any inherited value, sets the absolute launcher only when
one resolved, and pins ORCA_USER_DATA_PATH so a current CLI dials the instance that minted the id.
Renames the marker reader to hasStructuredSessionMarker and records why the terminal view carries
the id without the marker.

* fix(terminal): name this app's CLI launcher by absolute path in every local terminal

ORCA_CLI_COMMAND meant three things by lane: an absolute launcher for a structured session, a bare
name for WSL, and nothing for any other terminal, so a structured session's terminal view lost it.
Local terminals now get the same absolute launcher the structured lane gets; WSL keeps its guest
command name, and a terminal whose launcher does not resolve still gets none.

* feat(cli): hand a command to the session's own CLI when another Orca CLI was invoked

A login shell can reorder PATH behind a global install, and an agent or its helper script can run
bare `orca`, so the binary that answered depended on the agent following instructions. Orca's
packaged launchers and bare-orca shims now export ORCA_CLI_SELF (outermost wins). At the CLI entry,
when it names a different launcher than ORCA_CLI_COMMAND, the command re-runs once through the named
launcher with ORCA_CLI_REEXEC=1 and exits with its status; both variables are consumed so no child
inherits them. Dev launchers export no self on purpose, WSL and SSH names never qualify, and a
launcher that cannot start leaves the command to run here. The Windows launcher no longer rewrites
ORCA_CLI_COMMAND; the legacy ask protocol normalizes its resume command itself.

* refactor(orchestration): declare which flag names the caller on each spec and refuse at the CLI entry

Each handler hand-classified its --from/--terminal as the caller or a target, and the refusal of a
conflicting caller flag ran inside the caller resolver plus two standalone calls for --run listings,
so a new verb that read its flag raw would pass a sibling's handle to a pre-session host. Specs now
declare identityFlagRoles, the CLI entry refuses a conflicting caller flag once from the spec, the
resolver only applies the id-wins rule, and a test fails any orchestration verb that accepts --from
or --terminal without classifying it.

* perf(cli): keep the session caller check off the actor codec's module graph

The check runs at the CLI entry for every command, and the actor codec pulls zod through the session
record. Compare the session's own spellings as plain strings instead.

* refactor(cli): spell a session's address from the one prefix constant, off the codec's module graph

The Orca session address prefix moves to a leaf module with no imports, re-exported by
the address codec, so the CLI entry check derives `session:<id>` from that constant
instead of re-typing it and still stays off the codec's zod graph. Prose and test names
say caller or Orca session id, not actor.

* refactor(orchestration): drop the session id's terminal-view spawn now that the handoff is gone

The terminal handoff was removed, so no terminal is ever a structured session:
- delete the terminal-view identity env and its WSL passthrough, and their tests;
- strip the session caller keys from every terminal's env unconditionally;
- the CLI's own-address spelling moves beside the injected id in src/shared, with
  a test pinning it to the address the host's party resolver gives that session.

* fix(terminal): run the Codex launch preflight through the CLI the terminal names

Packaged Linux names the userData shim in ORCA_CLI_COMMAND, while the preflight
ran the bundled launcher behind it. The CLI saw a different launcher and handed
the preflight off to the shim, booting Electron twice before every codex launch.

* revert(terminal): keep terminals on main's ORCA_CLI_COMMAND and Codex preflight

Only a structured session needs an absolute ORCA_CLI_COMMAND; local terminals go back to
naming none (WSL keeps its guest command), and the Codex launch preflight goes back to the
bundled launcher. The CLI handoff is scoped to sessions, so a terminal's preflight can no
longer be handed off and start Electron twice.

This reverts commit d2cefb6c03 and commit dd2853a5a9.

* fix(cli): hand off to the session's CLI only inside a structured session

The handoff ran whenever an Orca launcher's ORCA_CLI_SELF differed from an absolute
ORCA_CLI_COMMAND, so any process with both - a terminal, a script - ran another install's CLI
instead of the one invoked: a beta's --version lied, and an AppImage command from a terminal
that outlived its Orca failed. It now requires the injected session id, the identity it exists
to deliver. The launcher variables are still consumed in every process.

* fix(cli): name the packaged Windows command after the handoff decision

The launcher stopped writing orca/orca-ide over ORCA_CLI_COMMAND so the handoff could see a
session's absolute launcher, which also changed what every Windows terminal's CLI read. The CLI
entry now applies the launcher's rule itself once the handoff is decided, so terminals and the
legacy ask resume command see exactly what they saw before, and the resume-command reader
goes back to its original form.

* refactor(cli): decide the session handoff from the CLI's own entry, not a launcher export

Every packaged launcher, shim and dispatcher exported ORCA_CLI_SELF so the CLI could tell which
launcher ran it, and compared that with the session's ORCA_CLI_COMMAND. Two launchers of the same
app are different files, so a session that reached its own app through a global orca-ide on Linux
still handed off and started Electron twice, and the export rode artifacts every terminal uses.

A structured session now also names the JS entry its launcher runs (ORCA_SESSION_CLI_ENTRY), and
the CLI compares its own argv entry with it: any launcher of the same app stays, another install
hands off. The launcher scripts, Linux shim and dispatcher go back to main; the Windows launcher
keeps only leaving ORCA_CLI_COMMAND for the CLI to name after the handoff decision.

* refactor(cli): drop the session CLI handoff; the pinned instance and injected id already bind any current CLI

Every current Orca CLI dials the instance ORCA_USER_DATA_PATH names and sends the injected
session id in the orchestration envelope, so a bare `orca` that reaches another install's
current CLI already acts as the session. An older CLI has no handoff code and refuses on the
marker. The handoff only lined up versions between two current CLIs, and comparing two
separately derived paths kept misfiring (an AppImage's mount against its registered
extraction started the CLI twice on every call).

Removes the re-exec, ORCA_SESSION_CLI_ENTRY and ORCA_CLI_REEXEC, and the CLI-side Windows
command naming; the packaged Windows launcher rewrites ORCA_CLI_COMMAND again, as on main,
inside its own process only. resolveHostCliEntryPath goes back to the SSH passthrough.

* test(orchestration): say why the registered worker case pins the handle, now that every session's env is populated
2026-09-28 15:19:44 -07:00
Neil 8c61a5df1f fix(windows): require signed release binaries and identify CLI launcher (#23680) 2026-09-28 14:34:08 -07:00
Neil cf20423ff3 ci: skip unrelated installs and share xterm build dependencies (#23607)
* ci: pilot shared xterm installed dependencies

* ci: bound xterm cache production to verified main entries

* ci: benchmark xterm reuse on the production ARM runner

* ci: avoid installing Orca dependencies for standalone xterm checks

* ci: use Node-only setup in the production xterm job

* ci: remove completed xterm benchmark workflow
2026-09-28 03:46:22 -07:00
Neil 0fe8974354 perf(ci): move six more jobs to the free ARM runner (#23594)
* perf(ci): move six more jobs to the free ARM runner

Follows the static-analysis move, which measured 172s to 128s. Each of these was
checked for an x86 requirement rather than assumed portable.

pr.yml:
  cross-version-wire        source-only, tagged checkout plus in-process vitest
  managed_hook_node18       Node 18 publishes linux-arm64; the per-platform
                            runtime files are read as data, so host arch is moot
  codex_index_heal_contract @openai/codex ships @openai/codex-linux-arm64
  shell_contracts           fish 4.x is published for noble/arm64, zsh is in the
                            arm64 archive, so the fatal fish-4 gate still holds

mobile.yml:
  verify                    209s of its 298s is Vitest; no Android SDK, emulator,
                            gradle, Hermes or Watchman, no docker, no artifacts.
                            Gemfile.lock lists the generic `ruby` platform, so
                            frozen bundler resolves without an aarch64-linux entry
  recording-pin             pure Node plus git; the golden comparison masks
                            `platform`

Left on x86 deliberately:
  package                   builds --x64 targets, its docker gates are
                            --platform linux/amd64, and it is where the glibc
                            floor check runs. node-pty's .symver pin is
                            arch-specific, so flipping would validate the arm64
                            pin and stop validating the shipped x64 one
  orcad_browser             Google ships no Linux arm64 Chrome
  mobile_web_app            same Chrome wall; its render check fails closed
  git_compatibility         its cache key carries runner.arch and the warmer is
                            x86, so flipping alone means a cold `make git` every
                            run
  relay_integration         no technical blocker, but x86 relay coverage is a
                            documented placement and the reusable workflow has no
                            per-job runner input
  e2e and the ssh lanes     the ssh jobs would silently retarget the tested
                            remote from linux-x64 to linux-arm64

* perf(ci): move xterm_patch_sync to ARM too

The patch check rebuilds 4 packages x 2 builds and byte-compares against the
checked-in bundles. Ran it on darwin-arm64: exit 0, in sync at 1362743 bytes,
with the full fetch-and-rebuild path exercised rather than a short-circuit. The
bundles were generated on Linux x64 and reproduce byte-for-byte on a different
arch and a different OS, so the output is host-independent.
2026-09-28 02:42:41 -07:00
Neil 2f8f4f576d perf(ci): run static analysis on the free ARM runner (#23576)
Measured 128s against 172s on ubuntu-latest, with every compute-bound step
faster: type-aware 24s to 15s, anti-slop 28s to 19s, localization extraction
67s to 46s, and the orcad terminal smoke 39s to 14s. Checkout and the install
were unchanged at 12s and 16s.

The toolchain resolves on arm64: both lint engines ship linux-arm64 bindings
(@oxlint/binding-linux-arm64-gnu, @oxlint-tsgolint/linux-arm64), and
build-orcad-bun.mjs derives its target from process.arch. The orcad smoke
booting and round-tripping a real PTY is the evidence that node-pty compiled
and that Bun, the bundled ripgrep and @parcel/watcher all resolved.

The runner is free for public repositories, the same one the typecheck job
already uses.
2026-09-28 01:59:34 -07:00
Neil d05080175b perf(ci): stop duplicating shared Linux download caches (#23578)
* perf(ci): pilot pnpm verification record caching on Linux

* test(ci): review pnpm verification record in mobile cache audit

* perf(ci): share Electron downloads and clean closed PR caches

* test(ci): retain cache ordering checks for restore-only consumers

* perf(ci): limit archive sharing rollout to primed Linux hosts
2026-09-28 01:58:46 -07:00
Neil 080c562898 perf(ci): diff against the merge commit's first parent so PR checkouts can be shallow (#23562)
Every changed-path gate asked git for `--merge-base "$BASE_SHA" "$HEAD_SHA"`,
which needs the event payload's base SHA to be in the local graph. That is the
only reason two jobs cloned all 8127 refs' history. On a pull_request checkout
HEAD is already the merge commit, so its first parent is the base side and no
merge base has to be computed. config/scripts/git-pull-request-diff-base.mjs
resolved that for the two Node gates; the workflow's inline gates now use the
same helper through a small CLI rather than open-coding it.

code_paths gates all 22 jobs, so its checkout is charged to the start of every
one of them: measured 20.7s to 1.6s, keeping blob:none because its sparse tree
is ~7 files and leaves no blobs to refetch. Static analysis drops the filter
instead, since populating all 30,226 files makes blob:none force a second
promisor fetch: 23s to ~11s.

Verified on a real merge ref. At depth 50 the old and new forms produce
identical changed-file sets. At depth 2 the new form still works and the old one
fails with `fatal: bad object`, which is the failure a stale base would have
caused once the checkout stopped being complete.

Also drops the dead resolveBase + merge-base prelude in the changed-code gate,
whose result resolvePullRequestDiffBase already discarded on every PR.
2026-09-28 00:37:26 -07:00
Brennan BensonandClaude 7a24d3d335 fix(native-chat): the conversation outlives its agent (#22835)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* fix(native-chat): the conversation outlives its agent

Opening a chat no longer starts its agent. A conversation is reached through one host
accessor that opens its journal at rest, and a send is what starts the agent, through
the delivery loop. One idle sweep, every five minutes, stops an agent that has been
quiet for thirty minutes and owes no work, then drops an open journal handle that is
only a cache. Its record, tab, status row and readers stay.

- hold and release are no-ops; hold still builds the host for shipped mobile builds.
- The holders, the holds, the release clock and the exit respawn are deleted.
- Options, the model list, the goal and the context meter answer at rest; a model pick
  at rest is recorded as intent for the next start.
- Compact, rewind, clear and goal changes start the agent first. A send does too when
  a rewind is still in doubt after the conversation opens.
- Orchestration routes mail and group addresses on ownership (the record plus the chat
  tab), not on whether the process runs. An open dispatch keeps its worker running.
- The restart continuation is a send; Resume all holds each slot until the message is
  handed over or rejected.
- A read error never replaces a loaded transcript, and shows the host's own words.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* fix(native-chat): a restart offer ends when the chat's agent starts again

The offer used to end only when the chat's newest user message changed,
because opening a chat started its agent and that start could not be told
apart from real activity. Opening a chat starts nothing now, so the host
reads the fact it already publishes: a chat's status row goes from not
host-owned to host-owned exactly when its agent is started. At that edge the
offer and any failure record for the chat are withdrawn, unless the start is
a resume action's own (its continuation is the oldest undelivered message).

A continuation and a message racing to be first are decided at acceptance:
the continuation is refused, quietly and with nothing filed, when any other
message was accepted since the restart. A failed continuation start leaves
the offer retryable, and each resume action sends its own message id.

Deleted: the newest-user-message comparison, its journal reader, the
continuation filter, and the failure ledger's own "answered by the chat"
check. The marker still carries its message id for one release, so the
previous build can read it.

* fix(runtime): end a transcript stream when its client unsubscribes

Desktop: the IPC subscription controller was dropped as soon as the streaming
handler returned, which for most streams is right after it binds. A later
runtime:unsubscribe then found nothing to abort, so the host kept the subscriber
and derived and sent every publish to a channel no one listened to. The controller
now lives until the renderer unsubscribes, resubscribes the same id, or goes away.

Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe
with the stream's frame id, so the host ends that subscriber and leaves a sibling
stream on the same socket running. The direct path now passes the frame id the relay
path already passed.

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* fix(native-chat): one fact ends a restart offer: the chat moved on since the restart

The offer is live while no other message has been accepted in the chat since the
restart and its agent has not proved a start since. The offer list, the resume's
reservation check and the continuation's acceptance check all read that one fact,
so a message whose start then failed withdraws the offer too, and a stale click
finds nothing to act on.

The fact is read off the conversation's open handle, which the restart closed, so
it is retired durably whenever it may have changed: a message accepted, a start
proven. A close and reopen within the same run therefore cannot bring the offer
back. A continuation rejected before it reached the agent does not count, so a
retry after a failed start still runs.

Deleted: the quit-time gate on withdrawal, which changed nothing because the
withdrawal and the quit's own offer write share one queue; the per-action
"withdrawn" flag and the separate acceptance check it paired with.

* test(native-chat): an older build reads the restart offer this build records

The offer lives in a file the previous release reads after a downgrade. Pin that
against the pinned release's own capsule, and run the lane when the marker or the
capsule changes.

* fix(native-chat): read a restart offer against where the journal stood when it was taken

"Since the restart" was read off the conversation's open handle, which the idle
sweep closes: after a reopen, a message the user had already sent looked older
than the handle and the withdrawn offer came back.

The offer now records the journal position (epoch and sequence) at the moment
it is taken, and a message accepted after that position, or a journal on another
epoch, means the chat moved on. That is derived from the journal, so it holds
across any number of closes and reopens. An older build's offer has no position;
only a start withdraws it. Because the message half is now durable, the offer is
no longer rewritten in the recovery file on every accepted message; a proven
start still writes it, since only the host that saw the start knows of it.

* test(native-chat): wait for the listing's retire write before reading the recovery file

* fix(native-chat): keep the terminal-backed chat's read error over its local echoes

Messages winning over a read error is right for the structured chat, whose read retries and whose
messages came from the transcript. The terminal-backed view assembles its list from local echoes
too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no
error. Only the structured pane now keeps messages over an error.

* fix(native-chat): a start retries the exit settlement a failed journal write left owed

An agent exit whose journal settlement write failed releases the lease latched until a retry lands.
Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried
it before the next app launch, and every send was refused. The start the send needs now runs the
retry first, where the attach would.

* perf(native-chat): answer the owner check without opening the chat

Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the
answer comes from the session record alone. Reaching it through the accessor opened each resting
chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it
open for the idle window. It now checks the record and the adapter's support, as before this series,
and opens nothing.

* fix(native-chat): a read waiting on the session lock opens nothing once quit began

The accessor checked for quit before queueing the open, so a read queued behind a session task ran
its open after teardown had begun and indexed a journal no teardown step would close. The check now
runs at the open itself.

* fix(native-chat): read a failed resume's chat before calling it retryable

Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the
restart, read from its journal. The failure list read it only for a chat already open, so once the
idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did
nothing. The list now opens the failed chats first, as the offer list does.

* test(native-chat): type the provider event sink the settlement test reaches for

* fix(native-chat): say the structured read keeps trying only where it does

The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an
untranslated fallback whenever the read error had no text, and the empty state prefers any message.
The view state now leaves the message out, so the structured pane shows that line and the
terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an
error frame, so it no longer makes the claim.

* test(native-chat): await the send's settlement instead of polling for the start

The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a
loaded machine outran. They now await the host's own settlement of the message.

* fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations

The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than
a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now
dated by the resume action.

Telling a rejected continuation from the user's own message read the operation ledger, whose rows
expire after about a day; after that a failed resume stopped being retryable. The offer now
records the continuation each action sends on its own capsule entry, bounded to the newest 16, so
the ids end with the offer. The ledger read is deleted.

* fix(orchestration): route no mail to a structured worker its orchestration released

A structured worker is routed on ownership, and a resting worker's lease is released, so ownership
held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found
at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one
restarted its agent. Routing now also reads the orchestration's own resource row: once it is
released, direct mail, group addressing and worker-show's addressable answer drop the worker, as
they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored.

* fix(native-chat): a failed retry names the user's prompt, not Orca's continuation

A resume's continuation is written to the chat before its start, so after a failed attempt the chat's
newest user message is that rejected continuation. A second failure then showed Orca's own restart
text as the chat's prompt. A retry now keeps the prompt its first failure named.

* fix(orchestration): read the released row optionally, as the authority does

worker-show's observation called the row lookup directly, which a runtime double without it threw on
and failed the structured tab-retirement release.

* fix(native-chat): the status bar drops a restart offer the chat moved on from

The renderer re-read the host's restart offer only when a failed chat showed activity, so after a
message withdrew a pending offer the host answered no chats while the status bar kept counting one,
and clicking it opened nothing. The same watch now covers pending offers: a status change in an
offered chat asks the host again, once.

* test(native-chat): a roster of idle or finished children does not keep an agent awake

The sweep reads owed background work through the shared child-work liveness that upstream's
release clock adopted; a child that went idle or finished is not work the agent still owes.

* fix(orchestration): a task dispatched into a resting structured worker keeps it running

The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal
resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a
dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while
that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's
process incarnation now counts, derived from the existing rows.

* docs(native-chat): comments stop describing the hold this PR removed

Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that
pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive
subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it.
Comment-only.

* fix(native-chat): a restart offer keeps the start its own continuation made

Whose start ended an offer was decided at read time, from whether the offer's continuation was
still the queued message. Once the provider refused that continuation, the child it had started
read as someone else's start, so the offer ended and its failure showed no Retry. The delivery
loop now records which queued message a start is for on the in-memory child, and the child's end
carries it; the offer counts a start as its own when that message is one of its continuations.

* fix(native-chat): an agent gets a full idle window after its owed work ends

The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or
dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can
read done before the lead's wake-up turn writes anything, and stopping in that gap loses the
wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full
window afterwards, as the release clock it replaced did.

* test(claude): the options-read fixture runs a live child

The fixture marked its conversation running with a hasProviderChild field the
session type does not have, so the read took the at-rest path and refused a
session with no record. It now carries a child, which is what the read checks.

* test(native-chat): host tests reach its collaborators through a typed seam

The rest-test rig and three test files read the host's private members with
Reflect.get and cast the result. The host now exposes one test-only accessor,
collaboratorsForTests(), and the subscribers class a subscriberCountForTests()
beside its existing retainedActivityCountForTests(), so the tests are checked
against the real types and the casts are gone.

* refactor(orchestration): one owner answers a structured worker's custody

Routing, group addressing, worker-show and the idle sweep each composed their own reading of
whether orchestration still holds a structured worker, so each new obligation or retirement state
had to be added to every reader. structured-worker-custody now derives both answers from the
worker-terminal list state coordinators see in worker-list: addressable is owned and not released,
and owed work is an active custody or an unsettled task dispatched to the same incarnation. The
owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup
already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests.

* refactor(orchestration): owed work is an open dispatch on the worker's incarnation

A supervised worker's own dispatch context stays open exactly while the worker is active, so the
separate active-custody branch only repeated it. Owed work is now one fact, which also states the
policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are
written once at the top of the module.

* fix(native-chat): a restart offer knows its continuations by a tag in their id

The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running
action's id in memory. Both could disagree with the journal: past the cap an old rejected
continuation read as the chat moving on, and a crash during a retry restored the failure's older
entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer
(its teardown and chat), then the action's own part, so any continuation of this offer, queued or
rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and
the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own
continuation the start was for, read against the stored marker.

* test(runtime): the legacy-worker reveal test judges its stale snapshot inside the wait

The tui-idle probe reads through readTerminal, which now awaits the structured
worker check before the PTY read, so the probe's snapshot request starts a
microtask later. vi.waitFor missed it on its first check and polled again at
50 ms, the same moment the wait's own 50 ms timeout fired. The stale snapshot
then resolved after the wait had already timed out, so the test passed without
judging it, and the rejection landed before any handler was attached. Vitest
reported that as an unhandled error and failed the shard.

Polling every 1 ms sees the request within a few ms, so the snapshot is judged
while the wait is still pending.

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* fix(native-chat): the idle sweep reads owed work every tick

Owed work counted as activity, but the sweep read it only once the idle window had elapsed, so it
refreshed the clock at most once a window. Work that ended just before the next read left the
agent to be stopped at that read, moments after the work ended, which is the gap the refresh was
meant to cover. The sweep now reads owed work on every tick for a started agent, so the window
always runs from the last tick that saw work owed.

* fix(native-chat): a continuation handed to the agent stays sent

The offer read its own continuation as not reaching the agent while its dispatch was pending, which
also covered one already handed over and still unanswered. When the wait for that answer ended first,
the failure it filed read as retryable, and a retry sent a second continuation to an agent that may
have acted on the first. Only a continuation still queued, or rejected, is now read as unsent.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): the interrupted create's own retry continues again

The merge of main's lease-latch fix replaced that test's retry of the interrupted create, under its
own operation id, with a fresh start whose result nothing read. That fresh start passes with the
released-reservation continuation deleted, so the case the fix exists for went untested. The retry
and its assertion are main's again.

* docs(native-chat): three comments that still had views starting agents

A start with nothing queued now comes from a command, goal change or rewind; an interrupted compaction
left alone would refuse every send, so no agent would ever start to finish it; and a current host
raises the unattached read refusal only once quit began, with the attach window belonging to an older
host.

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* test(native-chat): a reader's open settles the turn a failed exit settlement left running

An exit whose settlement write failed leaves its turn running in the open journal. PR 1's open now
settles it, and this pins the two reads that reach it here: a reader reopening a chat the idle
sweep closed, and a read that opens the chat before the restart restore reaches it.

* test(native-chat): the view-start test's starting window outlasts two subscriptions on a loaded runner

A subscription reads the conversation before it returns, so under load the two views took longer
than the create child's 300 ms start, which then exited before the test checked that it had not.
The child now takes a second to fail.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* test(native-chat): a read that reaches a crashed chat before the startup reconcile settles its turn

On desktop the chat on screen at relaunch reads before startup reconciles the leases, while the
dead process's lease still reads live. The open settles the turn it left running anyway, and the
restore that follows finds it settled.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* docs(native-chat): drop the removed dispatch hold from six comments

A worker's session no longer takes a dispatch hold, and no release clock
rests a chat by visibility; the agent-launch comments, the abandon test,
the teardown test and the refusal census still said so.

* test(native-chat): rest the owner-status chat through the idle sweep, not a hold

The activation-gate test from #22808 put its chat at rest by holding and
releasing it, and passed the release-clock grace. This branch deleted both,
so the case threw before it reached its assertions. It now moves the host's
clock past the idle window and lets the sweep stop the agent and close the
conversation, then asserts the same owner answer and activation gate.

* fix(native-chat): show the structured pane's retrying line when a read fails

The read transport always hands the pane the host's words, so the error
state's "Orca keeps trying to load it" line, which showed only when there
were none, was never seen: the pane showed the host's text twice, as its
subtitle and on the status line under it. The structured pane now always
says its read keeps retrying, and the host's text stays on the status line.
The terminal-backed chat is unchanged.

* test(native-chat): wait for a send's background start before the refusal oracle removes its store

An accepted send wakes the delivery loop, which starts the agent in the background. The oracle's teardown disposed the loop but did not wait for that start, so its lease write could create a temp file in the store directory while the directory was being removed, failing the test with ENOTEMPTY about one run in four. The teardown now drains tracked starts before it closes the journals.

* fix(native-chat): a start a message waited on gets one failure row, the delivery loop's

When a queued message's start failed, two writers could report it under the same row: the delivery loop, when the adapter settled the start without proving it, and the exit settlement, when the child's exit landed. The last one won, so the chat's row could name a different cause than the one the message was rejected with, or be written twice.

The exit settlement now writes the start's row only when no message is queued and the loop has not already recorded that start. A start for a command, goal change or rewind, with nothing queued, still gets its row from the exit.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-27 23:46:14 -07:00
Neil 9179b93ebf ci: reduce repeated runner work and validate affected-test selection (#23540)
* ci: stage heavy checks and measure affected-test selection

* fix(ci): exercise the real Git boundary in unit selection planning

* Harden review cancellation and CI demand reporting
2026-09-27 23:25:20 -07:00
Neil 67581dd090 perf(ci): stop the orcad smoke idling 15s and the static job fetching mobile packages it skips (#23541)
The shutdown race in the orcad terminal smoke never cleared its losing timer, so
the process sat on a live 15s timer after PASS had already printed. Measured
locally: 21.4s -> 6.52s, with the round trip and the shutdown assertion intact.

Static analysis also asked for the mixed root+mobile pnpm store (537 MB, 8.6s to
restore) on every run, while installing mobile dependencies only when the diff
needs them. Most runs paid 216 MB for packages they never linked.
2026-09-27 22:51:59 -07:00
Brennan BensonandClaude 85067494a1 fix(native-chat): a request that failed reads as failed (#22944)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* fix(native-chat): a request that failed reads as failed

A structured chat whose only message the agent's start refused read as a
green finish, and a cancelled structured turn did too: the host published a
verdict only for turn records, and structured rows carried no `interrupted`.

The host projection now reads the session's latest request: its turn's
outcome, or `failure` for a send the agent or its start refused. A send
that was withdrawn, or left undelivered by a restart or a close, fails
nobody and makes nothing listable. The ingest publishes `interrupted` as the
hook lanes do, and every reader decodes the verdict through one accessor, so
a failure reads Failed on the dot, the rollups, history and `worktree ps`,
behaves like a cancellation in every clean-finish policy, and notifies as
"failed".

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): a verdict change republishes the mobile status projection

* refactor(native-chat): the store's retention trigger keeps its flag compare

A verdict change always moves the completion clock the same check already
reads, so a second verdict compare there caught nothing new.

* test(native-chat): a user message the provider journaled keeps its session listed

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* fix(native-chat): a late provider-session update keeps a failed recovery record failed

A provider-session heartbeat that rewrites a completed recovery record kept
its interrupted flag but dropped the outcome it was copied with, so a live
failed checkpoint read as a clean finish until the next status write.

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* test(native-chat): the terminal-bell check asserts the renamed verdict field

The bell notification test still checked for agentInterrupted, which no
longer exists, so it could not catch a verdict leaking into a bell dispatch.

* fix(native-chat): a failed turn ranks like a completion for attention

Attention readers (completion time, Smart Sort, sticky retention, Cmd+J
Recent) now demote only a turn the user stopped. A failure is news the
user has not seen, so it keeps its completion time, ranks in the Done
class, stays retained after its pane goes away, and a retained failure
reads failed in the worktree rollup instead of done. Clean-finish
policy (hibernation, pane ownership, the value moment) still treats a
failure like a stop.

The retention trigger compares verdicts again: success -> failure no
longer moves the completion clock.

* fix(native-chat): a failed main agent reads failed while its subagents still work

The verdict is now read from the main agent's own state, not the folded
row: a main agent that is done and failed has a verdict even while its
subagents keep the row working. Without mainAgent (history, worktree ps,
older hosts) the old combined-done rule stands.

Display marks the verdict through agentVerdictDisplayMark: a failure
outranks every combined state on the agent's dot, label, tab badge,
dashboard and activity rows; a stop marks only a done row, so a
successful or stopped main agent with live subagents still reads
working. Subagent rows keep their own state. The worktree card, terminal
tab and Cmd+J rollups share one pane fold and rank a pending question,
then failed, then working, monitoring, interrupted and done.

worktree ps publishes the main agent's outcome on a working row, and the
mobile mirror reads it. The store's change check, the paired-client
mirror's equality and its epoch now see a verdict change on a working
row, which otherwise moves no state or clock and left the worktree card
reading working. Clean-finish policy is unchanged: a working row is never
hibernated and has no completion time.

* docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it

The field is new: an old host sends no outcome at all, so a reader falls
back to interrupted. The removed clause said old hosts send it on done
rows, which never shipped.

* docs(native-chat): the status-store listing rule names provider-journaled user messages

* fix(native-chat): a refused send notifies failed through the completion feed

The host's completion feed followed only the newest turn, so a send the
agent or its start refused, which creates no turn, read Failed on its row
but sent no notification. The feed now follows the session's latest
request, read from the projection the status feed already makes for the
commit: a turn keeps its id, a refused send is named by its journal item
key. It announces only while the session is idle, as the row reports a
verdict, so queued sends refused one commit at a time notify once, and a
withdrawn send falls back to a request already announced.

* fix(native-chat): every copy of a row carries the main agent's own status

History entries, sleep records and `worktree ps` rows carried a flattened
top-level `outcome`, copied under different gates and without the main agent's
clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type
the live row already persists and sends, and every copy site takes it with
`interrupted` through one function, `agentVerdictFields`.

- The accessor reads `mainAgent` then the legacy flag; the mobile mirror
  matches it line for line.
- Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a
  malformed value drops the field, never the record.
- Mobile dates a main agent that failed under live subagents by its own clock,
  as desktop does, and its row equality compares `mainAgent`.
- The activity feed reads a history entry's own `mainAgent` instead of
  rebuilding one; the sync key and history equality compare it.

* test(native-chat): pin the worktree ps verdict across host and phone versions

Pairs the real v1.4.212 host and phone row reader with this build: an old phone
reads a new host's rows by `interrupted`, a new phone reads an old host's rows
(no `mainAgent`) the same way, and a new phone reads a failure under live
subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout
now carries the phone's self-contained row reader, and the lane runs when the
`worktree ps` row producers change.

* test(mobile): name the parity table's row for its role

* fix(native-chat): a request that settles while the user is asked something notifies once

The completion edge waited for an idle session, and a pending prompt (including a
subagent's approval) is not idle. Structured chat has no other attention producer,
so a main turn that finished while a subagent waited on the user sent nothing
until the prompt was answered.

The edge now waits only on owed work (a running turn or an unanswered send), which
the projection reports even beneath a pending prompt. A request that settles with
a prompt pending announces once; the renderer words it "needs input" from the
host status mirror's `attention`, and answering the prompt keeps the same request
identity, so it does not announce again. The wire shape is unchanged.

* fix(native-chat): the completion says when the user is being asked

A request that settles while a prompt waits on the user was worded "needs input"
from the renderer's status-feed mirror. Remote clients receive the status and
completion streams over separate sockets, so they can arrive in either order and
the wording could be wrong both ways.

The host already knows at emit time, so the completion now carries an optional
`awaitingUser: true` in that case and omits it otherwise. The renderer words the
notification from that field alone and no longer reads the status mirror. Old
clients ignore the field and word by outcome; old hosts never send it.

* fix(worktree-status): a departed agent's failure yields to live work on the worktree card

A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome.

* docs(agent-status): a departed agent's failure ranks below live work on the worktree card

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(cross-version): load the phone row readers without mobile's toolchain

Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json
extends expo/tsconfig.base.json, which the root-only cross-version lane never
installs. The worktree ps verdict suite imported the current phone row reader
from mobile/ directly, so CI failed with TSConfckParseError before any test ran.

The harness now imports a copy of the working-tree reader placed under the
checkout cache, where the root tsconfig applies, as it already does for the
release checkout's copy. Both readers are still the real files.

* test(cross-version): keep the checkout path-guard message and justify the copy import's cast

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* fix(native-chat): a send the provider never received after a restart has no verdict

Restart reconciliation rejects a crash-stranded send that is absent from a
trustworthy provider history with reason 'not_delivered'. Nobody failed that
send, but the verdict allowlist did not name it, so after a crash the chat
read Failed, was listed, and could notify "failed". Give the reason a shared
constant (persisted value unchanged), add it to the no-verdict set, and treat
it as an internal marker so the Retry row no longer shows the raw string.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-27 22:23:49 -07:00
OrcaWinandm4air 47cebbf5d2 ci: use ARM unit runners, overlap web builds, and reuse verifier fixtures (#23376)
* test: reuse isolated mobile bundle fixtures for verifier checks

* ci: run PR unit shards on ARM and overlap independent web builds

* docs: record controlled CI overlap and runner measurements

* test: observe WebRTC packets with the host clock

* ci: isolate Windows installer CIM probe from native test load

* docs: record native probe scheduling validation

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-27 01:06:54 -07:00
OrcaWinandm4air 25c3ac400b ci: overlap shell setup, localization extraction, and mobile route preparation (#23368)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-27 00:28:02 -07:00
OrcaWinandm4air d8e2a694f6 ci: overlap package preparation and security scans; share localization parsing (#23364)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 23:53:19 -07:00
OrcaWinandm4air ccd1e87287 Overlap independent CI checks with native Actions background steps (#23351)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 23:09:40 -07:00
OrcaWinandm4air b5dec85a4e ci: reuse mobile web route analysis and skip unrelated mobile tests (#23329)
* ci: share mobile route analysis and scope mobile test runs

* ci: cover mobile web runner process dependencies

* test: verify mobile web selectors through the new runner

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 21:37:40 -07:00
OrcaWinandm4air 1882458f44 ci: scope orcad smoke and parallelize Linux packages (#23314)
* ci: scope orcad smoke and parallelize Linux package formats

* ci: validate packaging when its copy dependency changes

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 20:44:15 -07:00
OrcaWinandm4air 3eb1adec20 ci: reuse fixture setup and scope localization extraction (#23291)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 18:46:26 -07:00
OrcaWinandm4air 4b6fe95943 fix(windows): preserve relocated terminals and native process scans (#22872)
* fix(windows): ship the process-table addon to the relocated daemon host

The Windows terminal daemon runs from a copy of the app under
%LOCALAPPDATA%\Orca\daemon-host\<version>. That copy took node-pty but not
@vscode/windows-process-tree, so the daemon's bare require of the addon found
nothing and every process-table read (foreground tracking, descendant sweeps)
fell back to a powershell.exe Get-CimInstance scan (#16905).

- Copy the addon's runtime files (package.json, lib/, the .node binary) into
  the host; the ~25MB of gyp intermediates beside them are filtered out.
- Treat a host missing those files as unmaterialized, so hosts built before
  this are rebuilt, and skip relocation if the install itself lacks them.
- Log the daemon's native/CIM capability at startup and warn once when the
  process table falls back to CIM.

Revives #19525 on current main.

* test(windows): locate update-survival loss before relaunch

* test(windows): preserve daemon tree before update-survival proof

* test(windows): distinguish Electron exit from launcher close timeout

* test(windows): verify process exit when inherited pipes delay close

* test(windows): trace installer process checks in isolated survival runs

* fix(windows): probe process-query capability before installer sweep

* fix(windows): match installer probe and process-check profile behavior

* fix(windows): use NSIS separators for the process-check include

* test(windows): dismiss session-search overlay in survival harness

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 14:31:07 -07:00
OrcaWinandm4air d20cb69c48 Optimize CI follow-up workflows (#23190)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 02:53:37 -07:00
OrcaWinandm4air 3a081abf71 fix(persistence): reclaim Windows profile locks after PID reuse (#23122)
* fix(persistence): identify reused Windows profile-owner processes

* fix(persistence): preserve absent-owner recovery without native registry

* ci: build Windows registry before native profile identity checks

* fix(cli): include native profile-owner dependencies in typecheck

* test: register native profile owner test in Windows PR lane

* test: use resilient Windows profile-owner cleanup

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 02:26:10 -07:00
OrcaWinandm4air d17a17684b Reduce redundant CI runs, pnpm uploads, and fixture startups (#23145)
* Reduce redundant CI runs, store uploads, and fixture processes

* Avoid repeating draft-independent mobile checks on readiness

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 01:17:51 -07:00
OrcaWinandm4air 38bcdf76ac perf(ci): reduce queue pressure without paid runners (#23053)
* ci: measure complete unit file costs for shard balancing

* perf(ci): reduce repeated PR setup and capture complete shard timings

* perf(ci): seed reusable main-branch native and typecheck caches

* fix(ci): stop superseded unit workflows from resisting cancellation

* perf(ci): reuse bundle fixtures and share the baseline Git build

* ci: record hosted gains and refresh main hook parity

* ci: retain default workers after performance-budget regression

* docs: record hosted mobile timing flake

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-25 22:48:28 -07:00
Brennan Benson 067975bfd1 fix(native-chat): every lease latch has a way to die (#22820)
* fix(native-chat): every lease latch has a way to die

A failed exit settlement no longer leaves the lease in recovery: the release
writes no stage and keeps the exit in its death evidence, and whatever the dead
generation left running is settled from that evidence at the next acquire or
read restore. The settlement retry flag, its disposition and every branch that
read it are gone. A reservation that recorded no process is released at
startup and after a failed start, the never-written conflicted status and the
processless proof are deleted, recovery resolution always concludes, and Codex
records its child's identity at spawn, before the handshake.

* test(native-chat): a re-create needs a release proven by death evidence

* test(codex): the child's pid is reported before the handshake

* test(native-chat): type the crash and exit fixtures without casts

* fix(native-chat): wait out a terminal owner an older build recorded, in recovery rather than manual recovery

* test(native-chat): a chat mid-turn at quit reopens idle, and an older build reads an unproven release

* test(native-chat): explain the baseline store cast

* fix(native-chat): a terminal owner's refusal names the process instead of recursing

Opening a chat whose terminal owner an older build recorded threw a stack
overflow instead of the refusal that names the process to quit.

* fix(native-chat): wait out a terminal owner recovery cannot verify instead of releasing it

A terminal agent an older build recorded keeps its PTY across an Orca
restart, so a probe that cannot answer (a start-time read that fails on a
loaded host) is not evidence its transport is gone. Releasing it let a
native child resume the same conversation beside the live terminal agent.
Only proof of its exit now ends the claim.

* ci(cross-version): run the unproven-release downgrade test

The sharded unit job excludes tests/e2e/cross-version-wire, and the
cross-version job runs an explicit list that did not name the new test,
so it never ran in CI. A change to the record validator now also starts
the job.

* refactor(native-chat): map the retired manual-recovery stage to recovering at decode

Nothing in this build writes manual-recovery, and restart reconciliation
already rewrites it. Mapping it where the other retired handoff stages are
mapped removes it from the in-memory lease type and deletes the branches
that could only see it: the acquisition refusal, the renewer skip, the
unproven-release stage check, and the handoff-status 'manual recovery is
required' answer. Older builds accept recovering, so a record written back
still loads after a downgrade.

* docs(native-chat): say what happens to a live child an ownerless reservation leaves

The reaper runs once at store open, while the unreconciled lease still
claims the child's token, so it does not stop that child on this launch.
The comment claimed it did.

* test(native-chat): name the each-case label for its role

* fix(native-chat): continue a create retried after recovery released its reservation

The client retries a create it never heard back from under the same operation id.
Recovery had released that create's reservation, so the retry was refused
agent_session_ownership_unknown while its row was pending, and
agent_session_operation_expired once the row aged out, and the chat never started.
A retry whose lease nothing holds now continues as a fresh reservation at the next
fence, which also stops the old reservation's spawn from committing.

* test(native-chat): name the refusal a replayed create used to get

* fix(native-chat): one quit-the-terminal-agent message for a chat a terminal agent holds

A chat held by a terminal agent an older build recorded frees only when that agent
exits. Sending said to reopen the chat and opening it said two runtimes claimed
it; both now say the chat is open in a terminal agent, name its process, and say
to quit it. Error codes are unchanged.

* ci: run PR checks on the rebased head

* fix(native-chat): name a terminal owner's process only when its start time can tell it from a reused pid

* test(native-chat): relaunch from the dying host's durable state, so its still-pending attach cannot race the new host
2026-09-25 21:04:31 -07:00
Jinwoo Hong a05649de91 fix(terminal): prove an idle Git Bash prompt through its bin launcher (#22752)
* fix(terminal): prove an idle Git Bash prompt through its bin launcher

Git for Windows' bin\bash.exe is a launcher that runs usr\bin\bash.exe as a
child and waits, so an idle Git Bash pane's job always holds two pids and the
Windows shell proof never confirmed it. Accept exactly the launcher plus its
direct bash.exe child, checked against the identity process table.

* test(terminal): wait for the Git Bash prompt before asserting the hand-off job

* fix(terminal): prove a Git Bash prompt as one unbranched MSYS bash chain

Orca launches Git Bash as bin\bash.exe -c "chcp.com ...; exec \"$BASH\" ... -i",
and each MSYS exec leaves its pre-exec process alive as a stub, so an idle
pane's job is launcher -> stub -> interactive bash. Accept any job that is one
parent-to-child chain rooted at the launcher whose every later member is
bash.exe, instead of a fixed two-process shape.

* ci: register the Git Bash shell-proof win32 test in the package-test list

* fix(terminal): read the spawned shell as a path, and keep one shell map

A spawned shell path with a space (/Users/John Doe/bin/zsh) was split as a
command line, so the POSIX proof compared against "john" and never
confirmed. Local panes now keep only the spawned shell path and derive the
name from it; the Git Bash chain walk drops guards the member check already
covers.
2026-09-25 01:00:36 -04:00
OrcaWinandm4air 632ae1320b fix(daemon): reap terminal descendants during shutdown (#22232)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-22 04:32:26 -07:00
Jinwoo Hong da1c322b00 feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193)
* feat(mobile): one build-time constant decides native or OTA, default native

EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in
preferences.ts. Expo's babel preset inlines a literal process.env member
expression at build time, so a release bundle carries the answer as a constant
and anything but the exact string 'ota' — unset, empty, a typo — is native.
Every default build is therefore the native app, unchanged.

mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to
mount the page comes from the build and never from storage: a native binary
installed over an OTA one, same bundle id and same data container, still refuses
a stored 'true' without reading the key. An unset key reads on only in an OTA
build; a development build keeps its opt-in, and a stored 'false' wins
everywhere so the Troubleshoot toggle can switch an OTA build back to native.

That toggle now mounts wherever the flag can be on, which is the only way back
to the native screens in an OTA build, and its label names the build kind rather
than saying "(dev)". The bundle probe row beside it stays development-only: it
fetches.

The flag census gains two rules — one module reads the switch, in the member
form Expo inlines and not the bracket form, and one named function answers the
build kind — and the build-kind fence now lists the Troubleshoot route that asks
it. Docblocks that said a store build can never mount the shell now say it
mounts only when built for OTA.

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

* ci(mobile): one workflow input picks the shell, and no input means native

Both release workflows gain a `shell` workflow_dispatch choice, options native
and ota, default native, and hand it to the step that bundles the JavaScript as
EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and
the fastlane build_and_upload step on iOS; nothing else in either file sets it.

A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'`
yields native for them — the first OTA release is a dispatch with one field
changed, and every other run is the app we ship today.

Each build step prints the value it is about to build with, read back from the
same variable rather than from a second copy of the expression, so a run's log
cannot claim a shell the build did not use.

The new contract test evaluates that expression rather than matching its text:
absent, empty and 'native' all resolve to native, 'ota' to ota, and any
expression shape it cannot evaluate is a failure rather than a pass.

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

* build: the desktop packages the real page, and the placeholder is retired

build:mobile-web now runs the app builder and app verifier, and both take their
output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each
carrying a constant of their own — one definition of where the bundle lives, so
a drift cannot leave electron-builder's beforePack looking at an empty directory
while the builder reports a tree it wrote elsewhere. build:mobile-web:app is
gone; it was the same two commands.

src/mobile-web/ and its two scripts go with it. What the app builder shared with
them is split into three modules named for what they hold rather than for the
bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the
canonical asset serialization, buildId, hashed assets, the protocol window and
the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two
failure modes are Windows paths and symlinked entries), and
mobile-web-source-line-endings.mjs (the CRLF guard, now with a required
directory rather than a default pointing at the deleted tree).

The two suites that only needed *a* valid tree on disk — the beforePack guard
and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree
instead of bundling the whole mobile graph. It goes through the same manifest
writer the page does, so a manifest shape change still reaches them.

Also retired: the placeholder's tsconfig project and its typecheck lane, its
knip entry, its electron-builder exclusion and .gitattributes pins, and the
app-bundle test that asserted the shims stayed out of a builder that no longer
exists. pr.yml's page job builds the same bundle the package job ships.

Inert for native phones: they never fetch it.

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

* style(config): one import of node:fs/promises in the entry-detection suite

The changed-code quality gate's focused plugins read the two as a duplicate
import; the readFile line was left over from the split.

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

* docs: the comments that still describe the retired placeholder bundle

The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app
and shipped by nothing. That script, that directory and that fact are all gone:
it is built by `build:mobile-web` into the packaged bundle dir, and a phone
mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota.

Two Windows cache keys explained themselves by naming src/mobile-web and "the
two bundle builders"; config/** now covers the builder, the verifier and the
manifest writer, and the spike's key no longer waits on a Phase C flip that has
happened. The keys themselves are unchanged.

Three scratch directories in the app-bundle suites and one in the verifier still
spelled the retired output root. Renamed to mobile-web, which is what the build
writes; they are temp subdirectory names and nothing reads them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:18:33 -04:00
Jinwoo Hong e6aa90ff36 test(mobile): certify the browser pane's golden families and render it in a page (OTA phase C, C6.5) (#21777)
* test(mobile): pin the browser pane's golden families

The half pin for C6: 4 families, 15 goldens, every verdict the one C2's
rule predicts. Measured per family with vitest `-t` over the full
787-golden corpus, with C1's 103 reproduced golden-for-golden as the
control: 6 byte-identical, 9 result-absent-settlement.

No composed `c6-page-closure.ts`: a composed table is pinned against a
route and the browser is a pane, so C7's route is what composes this
with C1's.

The derivation census does not wait for that route. `mobileWebAppRoute-
Closure` becomes one case of `mobileWebAppModuleClosure`, which takes
any entries, so the pane's own closure can be read from the module. Two
cases: the pane alone reaches exactly the pinned four, and the pane
beside `app/h/_layout` adds exactly those four and no other, with the
layout reproducing C1's 22 as the control for the difference.

Closure at this base: 48 local modules alone, 34 beyond the layout, 30
under `src/browser` and four through the web siblings. The design said
23, all under `src/browser`; it was measured before C6.2 and C6.3 added
those siblings, so the pin carries the re-measured number.

`browser.screencast` has no golden at all, so this certifies the input
path and says nothing about the frame path.

Red first: with `browser.wheel` dropped from the table, both census
cases fail naming the missing family; restored, the file's 10 cases pass
and the parity suite reports "15 goldens in 4 families, 6 byte-identical".

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

* test(mobile): pin the frame budget against the shell's real frame

Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's
device scale from a skeleton it builds itself, and until now its only
check was another skeleton of the same shape in the same file: two
copies of one assumption agreeing with each other.

This measures the real thing. A frame with CDP's nine metadata fields
and a real `Page.screencastFrame` timestamp, encoded by C6.1's
`encodeBridgeScreencastFrame` and serialized by the real
`BridgeHostSubscriptions`, posted through the host harness: 303 bytes
besides the image, against a bound of 516.

Held above is not enough on its own — 213 bytes of slack is room for the
shell to grow the envelope by a field the page never hears about — so
the bound is reconstructed exactly instead. Every byte of that slack is
a number this frame prints narrower than a double can; adding those back
gives 516 on the nose.

The budget cases run a generated noise image at the budgeted scale, not
a committed fixture: the worst case is the image JPEG compresses least,
and a photograph sits a tenth of the way to it. 901,161 px at 0.545
bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of
the 655,360-byte cap. One envelope more and the shell drops it, which is
ruling 1 read from the budget's side.

Red first, two ways. Drop the metadata widening from the bound and three
cases fail, the sharpest being the real shell answering the frame the
page thought it could send with zero posts. Add a field to the shell's
own envelope and the reconstruction fails at 516 against 548, where the
existing suite stays green on all 14 — which is the drift this file
exists for.

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

* docs(mobile): record the measured frame bytes, correcting e7cd24ef10

The previous commit message says the shell posts 654,857 bytes for a
frame at the budgeted area. That number was not measured; I wrote it
from the budget arithmetic instead of reading it off the harness. The
measured value is 655,147, which is 213 under the cap rather than 503.

Nothing in the assertions changes — they compare against the cap and
the bound, never against a literal — but the figure now lives in the
file where it was measured rather than only in a message that has it
wrong.

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

* test(mobile): render the browser pane in a page and paint a real frame

The only place C6's whole frame path runs. Every other check reads one
half: the shell suites drive the host with no page, the page suites
drive the hooks with no shell, and the parity pin certifies the input
path from a recording.

Ruling 4: no route is added. `bundleMobileWebApp` already takes an
`appDir`, so this builds a one-route tree of its own and nothing under
`mobile/app` moves. The shell double grows a screencast lane to serve
it: it accepts a subscribe, posts `event.binary`, and prices each frame
the way `BridgeHostSubscriptions` does, so an over-cap frame is dropped
where the page can watch the stream survive it.

Six cases: the pane subscribes with `wantsBinary` and paints the frame
it is handed; a second frame flips the double buffer; an over-cap frame
is dropped and the next one paints on the same subscription; the grant
withheld produces the update-the-app copy and no subscribe at all; a tap
issues one `browser.mouseClick` at the centre of the source viewport;
and no request leaves the bundle's own origin.

Measured. The frame the pane asks this viewport for is 390x698, which as
noise is 201,924 base64 characters. The phone's mobile-mode frame is
780x1424 and encodes to 811,168, which is 124% of the cap and the reason
the area budget exists; the over-cap case uses 2400x2160 at 3,761,580,
574% of it. The tap maps to (194, 356) against a 390x712 source, one
device pixel off centre because the rendered width is 382.33 CSS pixels
for 390 source pixels.

One finding, recorded rather than fixed because it is not the pane's.
The page files a CSP `script-src` violation on every load, on any route:
Zod 4 feature-detects its compiled path with `new Function('')`, the
shell's `script-src 'self'` blocks it, Zod catches the throw and takes
the interpreted path. The page is correct and the report is filed
anyway. One case names it so a second `eval` is visible, and every other
case asserts no violation beyond it.

Red first, twice, both by reverting behaviour C6.2 landed. Stub out the
decode probe in `whenBrowserFrameDisplayable` and the flip case fails on
two identical frame digests. Point `updateBrowserImageSource` at the
host element instead of the surface child and the paint and flip cases
both fail. The first case's comment is corrected by the first of those:
it claimed a visible layer proved the decode-then-flip, and the frame
still paints with the probe gone, so the flip case is what proves it.

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

* fix(mobile): sweep the worst-case JPEG cost instead of taking one point

The reviewer is right, and it is worse than the report says. At 0.545
bytes per pixel, 90 of 143 viewports posted a frame over the cap, and
59 of the 111 the budget claims to fit were dropped outright by the
real shell — 390x712 at scale 1.8 among them.

The old number came from one 2400x2160 frame. A single large frame is
the cheapest per pixel in the whole range, so a worst case measured
there is not a worst case anywhere else.

Swept 143 viewports, widths 320 to 1400 and heights 480 to 1600, each
encoded by Chromium at the scale the real budget picks for it. Across
the 111 the budget fits, the cost ranges 0.54470 to 0.55351 bytes per
pixel. The constant is now 0.56: that maximum plus 0.00649, about 1.2%,
for the encoder version it was not swept on. The docstring carries the
sweep, the range, the margin and the date.

0.56 is a fixed point, not a guess. Raising the constant shrinks the
budget, which lowers the scale, which moves the cost; 0.555, 0.56 and
0.565 all leave the same 31 viewports over the cap, and every one of
those sits at the scale floor of 1, where the module already declines
to go blurrier and C6 ruling 1's drop rule is the protection. The new
test asserts both halves: nothing the budget fits goes over, and the
largest viewport it cannot fit is dropped by the real shell.

The sweep lives in `config/scripts` because it needs Chromium: the
frames are CDP screencast frames, so Chromium's encoder is the oracle
and a Node JPEG library would calibrate against the wrong bytes. It
drives the real budget, the real scale function and the real
`BridgeHostSubscriptions`, and runs in about 4 seconds.

Ruling 2's block in `browser-screencast-budget-at-the-shell.test.ts`
now says plainly what it measures. It feeds `noise(area * theConstant)`,
a byte count the constant itself produced, so it can falsify the
expansion and the drop rule but never the constant. It read as if it
validated the worst case, and it did not.

Red first: put 0.545 back and the sweep fails with 59 viewports, each
naming its scale and reporting `null` — the real shell dropping the
frame rather than posting it over the cap.

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

* fix(mobile): turn Zod's JIT probe off for the page, before any module

The page filed a CSP `script-src` violation on every load: Zod decides
whether it may compile by constructing `new Function('')` and reading
the throw as "no JIT here", the shell's `script-src 'self'` is exactly
that throw, and the browser reports it before Zod catches it. Zod's own
source gates the probe on `jitless` for this case.

`z.config({ jitless: true })` at the entry does not work, and the
reviewer's suggestion of putting it there was measured losing the race.
`$ZodObject` reads `allowsEval` when a schema is constructed, not when
one is parsed, so the first module-scope `z.object(...)` in the bundle
fires the probe — and esbuild evaluates the chunk holding zod and its
callers before the chunk holding any module of ours that imports zod. A
Function-constructor trap in the page put the call under `new ZodObject`
ahead of the entry's first statement.

`globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with
`??=` rather than replacing, so the banner can set the flag before any
module runs. That is where it now lives, beside the `process` shim and
under the same `MOBILE_WEB_APP_SHIMS` contract, which asserts it is
applied. Nothing is lost: the compiled path was never reachable in a
page under this policy.

The render check's `newCsp()` filter is gone. It dropped violations by
`blockedURI === 'eval'`, which would have hidden a real one, and every
case now asserts zero. The first case walks load and first paint, which
is where the second of the two reports fired. The dead `violations`
array is deleted.

Red first: blank the banner constant and four of the six cases fail,
each naming a `blockedUri: 'eval'` the filter used to swallow.

Finding, not fixed here and reported instead: the page bundles two
copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, because
`src/shared/zod-salvage.ts` resolves upward. That is 808 KB of duplicate
source. Aliasing `zod` to one copy in the builder fixes it and was
measured working, but it changes which zod shared code runs in the
shipped page, which is a call to make on its own rather than inside a
CSP fix.

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

* test(mobile): give the shell double the whole canCarry rule and the acks

The double reproduced one arm of `BridgeHostSubscriptions.canCarry`, the
message cap, and silently carried anything the other two would have
refused: a window already holding its maximum frames, and a window whose
bytes the frame would push past the limit. It also ignored the page's
`ack` frames, so its window never reopened — which was invisible only
because no case streamed far enough to close it.

Both arms are in now, and the `ack` arm consumes the page's acks exactly
as the host does. The three caps are read out of `bridge-caps.ts` and
`bridge-host-subscriptions.ts` rather than retyped, the same way the
harness already reads the protocol version and the CSP, so a double
carrying a stale number is not possible. The render check's own
`640 * 1024` is gone with them.

One case for it: thirty frames of about 200 KB, roughly 6 MB through a
4 MiB window, nothing over the message cap, so a drop can only come from
the window. Every frame posts, nothing is dropped, and the page's ack
seqs are read back to show the window stayed open because the page acked
rather than because the double was generous.

The file docstring said the double answers no RPC. It serves a
screencast stream now, so it says that instead, and says what it still
is not: it decides no domain behaviour.

The dead `violations` array is gone, folded with the CSP commit.

Red first: make the `ack` arm inert, as it was before this commit, and
the case fails with `Set{'posted','dropped'}` against `Set{'posted'}`.
A first attempt at that mutation left the byte subtraction in place and
stayed green, which is the mutation being wrong rather than the case.

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

* ci(mobile): run the whole mobile-web-app family, not a list that goes stale

The `mobile_web_app` job hand-listed ten files. The render check this
chain added was not among them, so it would have skipped in CI — and it
was not the first: three landed censuses were already unlisted, and
their closure blocks only run with `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED=1`,
so they are green in the sharded `test` job whether or not they ever
ran here. Nobody could see it.

The list is now two vitest filename filters, `config/scripts/mobile-web-
app-` and the one builder test outside that prefix. Quoted, because
vitest matches a positional as a substring against the discovered files
rather than expanding a glob: `mobile-web-app-*.test.mjs` finds nothing,
and it fails by reporting no test files rather than by running fewer.
Both forms were tried before this one was written.

It runs 18 files and 205 cases, against 10 files before. With mobile
dependencies absent, 111 of those 205 skip, which is the measure of what
only this job runs. Per file, cases CI has never run:

  browser-pane-render            7 of 7   (this chain)
  source-control-external-links  9 of 9
  source-control-keyboard        6 of 6
  source-control-text-inputs     6 of 19  (C4.2)
  frame-budget-sweep             4 of 4   (this chain)
  route-manifest                 2 of 17

Two more files the filter adds run fully in the sharded job already and
change nothing here: `browser-pane-text-inputs` (C6.4 — it censuses a
hand-written closure and never bundles, so unlike the report it was not
skipping) and `external-link-seam`.

The sweep is renamed into the family for the same reason. As
`mobile-browser-frame-budget-sweep.test.ts` it matched neither the job's
filter nor `pr-code-change-scope.mjs`'s `config/scripts/mobile-web-app-`
prefix, so a change to it alone would not have run the job that runs it.
It is also gated on the dependency check now: it needs no
react-native-web, but it launches Chromium, and that flag is what tells
the job with a browser from the one without. Unguarded it would have
failed the sharded `test` job outright.

That filter is a prefix match. `config/scripts/mobile-web-app-` and
`mobile/src/` both fire this job, and `.github/workflows/pr.yml` is in
GLOBAL_FORCE_PREFIXES, so this commit runs everything.

Also: `postedFrame` in the ruling-2 pin and in the sweep both reached
the binary lane through `?.`, so a subscribe that opened no stream read
as zero posts — indistinguishable from a dropped frame, which is the
verdict both files are about. They throw now. The render check's
restated `640 * 1024` went with the window caps in c73b405f81; the cap
is read from `bridge-caps.ts`.

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

* ci(mobile): record what the mobile-web-app job costs to run

The filter that replaced the hand list runs 18 files where the list ran
10, so the step's cost is now a function of what anyone names into the
family rather than of what a reviewer remembered to add. Measured on
this machine: 25-30s wall for the whole step, of which the frame-budget
sweep is 2.5s.

The sweep is the one part whose cost is a choice. It encodes 111 noise
JPEGs in Chromium, one per viewport the budget fits, so adding rows to
that set is a decision about this job's runtime and the comment says so
where someone would make it.

Found, not fixed, and reported for its own PR rather than folded here:
the page bundles two copies of zod, mobile's 4.4.3 and the repo root's
4.5.4, reached through `src/shared/zod-salvage.ts`, which resolves
upward while `mobile/src/` resolves to mobile's. That is 808 KB of
duplicate source and two module instances in the shipped page. Aliasing
`zod` in `mobileWebAppBuildOptions` fixes it and was measured working
during this chain; it is reverted and stays reverted, because it changes
which zod shared code runs in the page and that is not a call to make
inside a CI commit. The CSP fix in 71254ab3a9 does not depend on it:
`globalConfig` lives on `globalThis`, so the banner covers both copies.

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

* fix(mobile): load the frame-budget sweep's mobile modules after the dependency guard

vite transforms every file under mobile/ against mobile/tsconfig.json, which extends
expo/tsconfig.base.json; the sharded test job installs no mobile dependencies, so the
sweep's static imports failed the file at load before describe.skip ran. Type-only imports
stay static; the values load in beforeAll behind mobileWebAppDependenciesPresent().

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

* test(mobile): certify the frame budget at the quality the pane ships

Round 2: the sweep and the render check encoded fixtures at a retyped 0.72; both now read
BROWSER_FRAME_QUALITY (the sweep from the module, the render check through the harness reader),
so a quality change fails the certification instead of leaving it green. Every render case now
asserts zero CSP violations; the sweep pins the 32 viewports left at scale 1; three references to
a renamed file and a file that never existed are corrected; a shim count comment is made
count-agnostic.

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

* test(mobile): assert the frame-budget sweep against the constant, not the measured maximum

The margin above the measured 0.55351 is what an encoder drift is allowed to spend; pinning the
measurement made a drift inside the margin fail a budget that still held. The number stays in the
docstring as the sweep's record.

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

* test(mobile): move the sweep's measured-maximum note beside the assertion it explains

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 07:43:14 -04:00
Jinwoo Hong ac024d4f05 feat(mobile): serve the files explorer and preview from the page (OTA phase C, C3.1) (#21710)
* refactor(mobile): take the files screens' router from the handoff seam

Inside the shell's page a screen is one document standing in for one screen, so
a target the page does not render has to be handed back to the app that does.
`useRouteHandoff` is where that decision lives, and its web sibling is the only
thing that makes it; both files screens held expo-router's own `useRouter`, so
on the web the explorer's Back and the preview's Back would post nothing and a
target outside the page would paint Unmatched over the page it is on.

Natively this is the same object — `route-handoff.ts` is `useRouter()` — so no
behaviour moves here, and `back()` stays expo-router's until the navigate-back
verb lands and the seam starts wrapping it.

A census rather than a behaviour test: neither screen's own tests can see the
difference, because a push that is never handed off still works for a target
inside the page. It walks this directory, refuses a value import of
expo-router, and names the two screens that must hold a router so a walk that
found nothing fails instead of passing empty.

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

* feat(mobile): let the shell stand in for the two files routes

Both route files take the index.tsx shape — flag, MobileWebShellScreen, native
screen as fallback — and both gain the `.web.tsx` sibling that shape forces.

Inert until the manifest lists these routes: the shell answers `native-route`
for a route the bundle does not name, which is what `fallback` renders, and the
flag is `__DEV__`-only besides. Listing them waits on C2.3 and C2.5.

The sibling is not a precaution. The manifest defers every route behind
`import()`, so a native-only route module is invisible until the page opens
that route; the render check now opens both and, without the siblings, painted
`expo-modules-core.requireNativeViewManager is not available on web` instead of
the screen. That is also why the two cases render the route rather than
asserting a file exists.

The file path never becomes a path segment: only `hostId` and `worktreeId` are
spelled into the pathname, encoded, and everything else — `relativePath`,
`absolutePath`, `cwd`, `pathText` — is a param, which is how a `/`, a space or a
`..` stays out of the segment vocabulary the bridge holds a route to. The
preview render case proves the round trip on `docs/my notes/readme.md`.

`mobileFilePreviewShellParams` drops a param the normalizer left `undefined`
rather than sending it empty, because the page reads these back through
useLocalSearchParams where `line: ''` and no `line` are different screens. Its
test drives the normalizer rather than a hand-written literal: the literal omits
the key entirely, so it held with the filter removed.

The preview case also records what React Native Web says out loud — BackHandler
is inert on web, so Android back inside the page skips the unsaved-draft
prompt. Named in the assertion rather than filtered out, so closing it is a
change to that line.

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

* fix(mobile): ask about an unsaved draft in the screen, not through Alert

React Native Web's `Alert` is `static alert() {}`. Inside the shell's page that
made Back with an unsaved terminal-artifact draft a button that did nothing at
all: no prompt, because the dialog is a no-op, and no navigation either, because
the code took the branch that shows one. Silently, with nothing on the console.

The prompt is now a row under the header. Not `ConfirmModal`, which every other
confirm here uses: that is a `BottomDrawer`, and C1.9 has Reanimated's animated
styles never reaching the DOM node on WKWebView, so on iOS in the page the
drawer parks off-screen and Back would be dead a second way. This paints the
same on every platform with no animation behind it.

Hardware back is registered natively only. React Native Web's
`BackHandler.addEventListener` logs "BackHandler is not supported on web and
should not be used." and hands back an inert subscription, so the guard never
armed there regardless; the render check asserted that console error on main and
now asserts none. The degradation is real and stated rather than hidden: Android
back inside the page pops the native stack without asking, and the page's own
Back control is where the question lives.

The decision moved to a hook so it is testable without a screen: the prompt also
drops itself when the draft it was about is saved or reverted, which is a state
`Alert` had no way to be in.

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

* fix(mobile): keep expo-haptics' DOM shim out of the page

expo-haptics has a web build, and with no `navigator.vibrate` — iOS Safari,
which is the WebView the page runs in — it fakes a haptic by appending a hidden
`<label><input type="checkbox" switch>` to `document.head`, clicking it, and
removing it, once per call. C1.9 traced a long press that never fired on the
worktree list to exactly that stray click, and the file explorer calls
`triggerSelection` on every row tap, so C3 is the first domain to fire it per
tap rather than per long press.

`haptics.web.ts` answers the same five names with nothing. A phone holding the
page is a phone whose native app is right there with the real haptics, and a
missing tap feedback is worth less than a tap that does not register.

The test reads the shipped bytes rather than the import, because that is the
claim: with the override removed the bundle carries `ariaHidden` and
`pointer: coarse`; with it, neither, nor the `setAttribute("switch"` that does
the clicking. Not `navigator.vibrate` — react-native-web's own Vibration export
calls that and touches no DOM until something invokes it, which cost this test
one wrong red before it was narrowed.

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

* fix(mobile): keep the files routes native when the page could not be given one

A file path is a param, so `/`, spaces and `..` all cross safely — but
`BRIDGE_MAX_ROUTE_PARAM_CHARS` is 1024 and a Windows long path is not bounded by
anything the user cannot exceed.

The symptom is not the blank document the design predicted, and the correction
matters: `bridge-host.ts` already parses the route against the page's own schema
and drops it to `null` when it fails, so `init` arrives naming no screen and the
page paints "Update Orca to open this workspace" — a wrong message about a fine
app, over a native screen that works. Deciding before the switch instead leaves
the route native, which is where every route starts.

The schema is the predicate rather than a copy of its bounds, so the rule cannot
drift from the half that matters, which is the half the page reads. The same
call also refuses a `worktreeId` the segment rule will not route: `..` survives
`encodeURIComponent`, which is the C1.8 class.

The tests assert the schema really refuses each input before asserting the guard
does, so neither case can pass by being impossible.

This belongs in the shell beside the schema; it is in the files domain while the
contract files are the C2 lane's.

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

* test(mobile): pin what keeps a file path out of the route vocabulary

Seven shapes, one case each rather than a representative: a plain path, a space,
a dot segment, an already-encoded slash, a fragment, non-ASCII, and an absolute
path. Each is checked in the two directions a path travels — the href the shell
writes into the page's history, and the href the page would hand back — for both
the pattern accepting it and the path coming back out of the query unchanged.

The counterfactual is in the file: the same paths spelled as a segment are
refused. Without that, the cases above would hold for a rule that was never
doing any work. Mutating `stringifyRouteHref` to join its query by hand instead
of through `URLSearchParams` fails three of them.

Also fixes two new test files the tests-typecheck ratchet caught: the partial
`react-native` mock needs a typed `addEventListener`, `act` will not take a
callback that returns a value, and `findAllByType('Pressable')` does not
typecheck against `ElementType` — the neighbouring files that do it are
grandfathered, so the tag comparison goes through a helper instead.

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

* fix(mobile): derive the discard prompt instead of clearing it in an effect

Both changed-code gate findings, which the lane had not run until the last
commit. React Doctor is right: the effect that cleared the prompt when the draft
went away adjusted state after a prop changed, so a save landing while the
prompt was up painted one frame still offering to discard nothing. The prompt is
now `asking && hasUnsavedDraft`, which cannot be stale by construction, and the
test that covers it passes unchanged.

The hoisted mock's `as` on a string literal is gone too: the literal narrows on
its own and the tests reassign it, so the holder is annotated instead.

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

* test(mobile): add the files routes to the hybrid shell flag census

The census pins every file that reads `useMobileWebShellEnabled`, because a
reader nobody listed is how a dark feature stops being dark. C3's two routes are
deliberate entries: each has a native screen behind it as `fallback`, and each
is inert until the manifest lists the route.

Found by the full mobile suite rather than by the files subset this lane had
been running per commit.

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

* feat(mobile): serve the files explorer and preview from the page

The last C3 commit: both routes join MOBILE_WEB_PAGE_ROUTES, and the shell
starts rendering the page for them on a phone with the dev flag on.

Grants are not the same for the two, and the difference is the point. Both take
`navigate` (Back pops the native stack, and the explorer's rows open the preview
beside it) and `storage` (the shared components the host layout renders above
them). Only the preview takes `externalLink`: a Markdown preview renders links
and `MobileMarkdown` opens them through the platform seam.

The explorer does not, and measuring is what says so rather than reading. Every
page route reaches `external-link.web.ts` — `/h/[hostId]` and agent-history
included, both granted nothing for it — because the protocol wall in the shared
host layout imports it. So closure membership is not the oracle for a grant; the
question is whether the route's own screens call it, and only the preview's do.
`MobileMarkdown` is in the preview closure and absent from the explorer's, which
the census now asserts in both directions.

Neither route writes a clipboard, so neither takes `native.clipboard.write`;
the census pins that as the absence of both `ExpoClipboard.web.js` and the
clipboard seam, with the tasks closure as the control that the probe can see one
when there is one.

The seam predicate moved into a module both censuses import rather than being
restated per series: two spellings of one rule drift, and this one is a regex.

Red-first: both manifest assertions failed on the new entries before they were
updated, and routing `MobileMarkdown` around the seam fails the preview's census
while leaving the explorer's passing, which is the asymmetry the grants encode.

Closure sizes as the page ships them, extensionless so the `.web.tsx` is what is
measured: explorer 3439 modules / 302 local / 10 under src/files, preview 3667 /
331 / 20.

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

* fix(mobile): read the files route's ids as one value and key the shell on them

Two round-1 findings, both reproduced before the fix.

A repeated query key reaches `useLocalSearchParams` as an array, and the
explorer read `hostId` and `worktreeId` bare. `String(['a','b'])` is `a,b`, so
the template built `/h/host-a%2Chost-b/files/wt-1%2Cwt-2` — a single segment the
bridge's rule accepts, and the shell would open a page for a host nobody has.
Read through `firstParam` now, as the tasks and agent-history switches do. The
preview already went through `singleParam` and is unchanged.

Neither switch keyed `MobileWebShellScreen`, where `index.tsx`, `tasks.tsx` and
agent-history all do. A host captures the grants its session opened with, so a
screen reused across a route change keeps authorising frames under the grants of
the route the page has left; only a remount drops that bridge. Both are keyed on
the route pathname now, with agent-history's reason.

The new route test is the agent-history one's shape. It caught both: the array
case landed on no route at all, because `name` was an array too and the schema
refuses a non-string param value, and the two lifecycle cases saw a prop update
where a remount was owed. It also needs agent-history's `lucide-react-native`
mock, since `firstParam` lives in the source-control barrel.

`name` is now omitted when empty rather than sent as `name=`, matching the two
switches beside it: an absent label lets the panel derive its own.

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

* fix(mobile): confirm a discarded draft with the app's own modal

Round-1 findings 3, 4, 5 and the minor one.

**ConfirmModal, not the bespoke row.** The row existed because C1.9 had
Reanimated's animated styles never reaching the DOM node on WKWebView, which
left every BottomDrawer parked off-screen. C1.10 (`b7c06900e2`, an ancestor of
this branch) fixed that with a dependency array on the mapper hooks, and the
drawer render check now holds it on WebKit as well as Chromium. With the reason
gone the row does not stand on its other merits: `Alert.alert` was modal on
native before the page existed, and the row quietly changed that for phones
too, so the app's own confirm is both the idiom and the closer behaviour.
`MobileFilePreviewDiscardPrompt`, its test and its thirty style keys are gone;
the hook's state machine and its tests are unchanged.

**The encoding test claimed more than it pinned.** Hand-joining the query reds
only three of the seven shapes; `docs/readme.md`, `../etc/passwd`,
`docs/日本語.md` and `/logs/run.txt` are encoding-neutral in the query, whose
pattern half is `[^#\s]*` and admits a slash, a dot segment and non-ASCII
verbatim. Rather than narrow the claim in a comment, the split is now pinned by
behaviour: each neutral shape must survive the query unencoded, each
load-bearing one must not. Moving `docs/readme.md` between the lists fails it.

**The manifest comment named one shared-layout opener and there are two.** The
New Workspace source field, which the sidebar renders on a wide layout, opens a
URL through the seam as well. Both are the shared layout's and every `/h` route
reaches both, `/h/[hostId]` included with no `externalLink`, so the tablet tap
is dead on all of them — recorded here as pre-existing rather than fixed, since
the grants do not move.

**Minor:** the dot-segment case in the guard test now asserts the schema refuses
the route before asserting the guard returns null, as the length case does.

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

* fix(mobile): stop every page drawer logging a BackHandler error when it opens

Round-2 findings.

**The registration belongs to the drawer, and that is where the guard went.**
`mounted-bottom-drawer.tsx` armed `hardwareBackPress` whenever a drawer was
visible and interactive, with no platform check, so the hook's claim to have
dropped that console line held only while its prompt was closed — and every page
drawer since C1 has logged it on open. Platform-gated at the drawer now; the
hook's comment says so rather than claiming the credit.

**Nothing had ever opened a modal in a browser.** The render check next door
mounts both files routes and reads what they paint but taps nothing, so
`ConfirmModal` inside the page — a BottomDrawer, so Reanimated, a portal and a
gesture handler — was unproved. A new render file loads an editable terminal
artifact through the harness's scripted reply, edits it, taps the page's Back,
and asserts the prompt's title is up and no BackHandler line is on the console.
Red first on exactly that line; the prompt itself painted, which is also the
first proof on a browser that C1.10's fix carries a real drawer in the page. A
second case answers Stay and checks the draft survives. Its own file rather than
the render check's, which is at 482 of the 600-line cap; registered in pr.yml.

**The encoding rule was stated wrong.** Two rules decide it and neither is about
paths: the pattern's query half refuses whitespace and `#`, and
`URLSearchParams` is form-urlencoded, so it reinterprets `&`, `+` and a valid
`%XX`. `a+b.ts` reads back `a b.ts` and `a&b.ts` reads back `a`, so both are
load-bearing; `a=b.ts` and `a%b.ts` are not, because only the first `=` splits
the pair and a lone `%` begins no escape. A newline joins the load-bearing list
as the refused shape rather than the altered one.

**The web sibling read its params bare** where the native one uses `firstParam`.
Not reachable — the page only arrives through `init.route`, whose params are
already `Record<string, string>` — but the two files are meant to be one screen.

The preview keys on the pathname alone, and the comment now says why that is
enough: every caller in this tree pushes.

Closures after this: explorer 3441 / 304 / 10, preview 3666 / 330 / 19. The
explorer grew two modules because its web sibling now reaches `firstParam`.

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

* fix(mobile): give the explorer the grants the preview needs, and key on the route

Bot findings, one of them a real gap.

**Pullfrog is right, and my grant oracle was half a rule.** Grants resolve once,
from the route the shell opened: `grantsForRoute` reads `session.routePathname`
and `init.grants.native` carries the answer for that session. The explorer's
rows push to the preview, and because the preview is a page route that push
stays inside the same document — no second `init`. So a preview opened that way
runs under the explorer's grants, and a Markdown link in it was refused by
`notifyExternalLink` with nothing on screen to say why. "Does the route's own
screen call it" was right for a route's own screens and wrong for the routes it
reaches in-page, so the explorer now declares `externalLink` as a transitive
grant, with the comment saying that rather than claiming it opens links. The
census pins the pair as a superset; removing the grant reds it.

**The seam regexes matched one quote style.** A double-quoted `react-native`
specifier walked past both censuses unseen. Both styles now, with the predicate
tested directly for the first time.

**The discard request outlived its draft.** `asking` stayed set after a save or
a revert, so the next edit re-showed the prompt with no Back request behind it.
The request is now dropped when the draft it was about goes, adjusted during
render rather than in an effect — the shape React Doctor named in the round-1
fold. Red first: save with the prompt up, edit again, prompt is back.

**CodeRabbit's keying comment is a correctness point, not the question I
answered.** The page learns its route exactly once, out of `init`, so a
same-path param change — another file in the same worktree — left the shell
mounted and the page still showing the file it was opened on. My comment claimed
"the screen reloads the preview from the param either way", which is true only
with the shell absent. Both switches key on the whole route now, params
included; two tests cover the same-path case and both red on a pathname-only
key.

`build-mobile-web-app-bundle.test.mjs` hit 601 of its 600-line cap on the way,
so the two manifest assertions now share one expected list instead of repeating
it. Closures unchanged: explorer 3441 / 304 / 10, preview 3666 / 330 / 19.

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

* fix(mobile): make the seam test import the module it is testing

Round 3.

**The blocker is mine and the reviewer's diagnosis is exact.** The seam
predicate test imported an absolute path into this lane's worktree. On CI that
module does not exist and it takes the whole `config/scripts` suite down; here
it resolved to the same file by accident, so the test was green against a tree
rather than against the checkout — which is why reverting the double-quote fix
left it passing and the predicate untested. Relative now, and proved: reverting
the fix in place reds both double-quoted cases, which is the first time this
test has failed for the right reason. Every file this PR touches is grepped for
`/Users/` and `orca-lanes`; none carries a path.

**Three comments outlived the grant change.** The two lists became equal when
the explorer took `externalLink`, so "longer than the explorer's" and "declared
with different grants" were both false. Corrected to what is actually true: the
lists are equal and the reasons are not — the preview has its own consumer in
`MobileMarkdown`, the explorer has none and declares the grant because its rows
push to the preview in-page.

**The duplicated serializer is pinned rather than imported.** `shellRouteHref`
lives in `page-bootstrap.ts` beside the page's RPC client and its document
channel, so a native route file importing it would pull both into the app. The
copy stays, and a test asserts the two agree on three routes; dropping the
empty-search branch reds it.

**Recorded, not fixed:** the sidebar `HostScreen` pushes to `/h/<id>/tasks`
through the handoff, which is local, so on a tablet the tasks page runs without
`native.clipboard.write` from any page route and its copy actions refuse
silently. Pre-existing since C2.1 for the worktree list and agent history. Named
in the explorer's manifest comment as the known remaining hop, with the fix
being a handoff rule in its own PR.

The equality pin needed `it.each<BridgeInitRoute>`: the inferred table is a
union whose members carry `?: undefined`, which the ratchet caught.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 15:47:25 -04:00
Jinwoo Hong 76063e7ab1 test(mobile): certify the tasks page closure, 70 families and 266 goldens (OTA phase C, C2.6) (#21712)
* test(mobile): read a page closure's run totals through one reader

The C5 gate counted the run's classes inline. C2 needs the same count over its
own closure, and two spellings of "what the run tallied" can disagree while both
stay green, so the loop moves next to `pageClosureTotals` where the table-side
count already lives.

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

* test(mobile): certify the tasks page closure, 70 families and 266 goldens

C2 moves the tasks screen to the web, so the goldens recorded at a call site
inside `app/h/[hostId]/tasks.web.tsx` and `app/h/_layout.tsx` are the ones whose
divergence would be this domain's. Each is pinned by id: the suite's own counts
run over 787, where one of the other 521 can pay for a closure golden that
stopped replaying.

C1's 22 families are inherited verbatim rather than re-derived — C2's rule
disagrees with them on 10 of the 103 — and the rule decides only the 48 this
domain adds. The pin is split at the domain's seam, one work item opened versus
choosing which to open, because the table is 409 lines of data and `max-lines`
is not a thing to disable.

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

* test(mobile): correct the C2 pin's inheritance count and census scope

Two comments overstated what was measured. The rule disagrees with 13 of C1's
103 inherited pins, not 10 — the 10 was copied from C5's file, which carries the
same error over the same 22 families — and the breakdown is now named so the
number can be re-derived rather than trusted.

The census reads the committed table and does not re-derive the closure, so a
golden arriving in a pinned family is caught while a new family entering the
closure is not. That was true and unsaid, which is the worse of the two.

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

* test: derive the tasks page closure's family set instead of trusting the pins

Round 2 folds, three.

The pin tables walk the families they already hold, so a scenario recorded at a
call site the route already imports lands in a family nobody pinned and every
assertion stays green. `mobileWebAppRouteClosure` runs in a quarter second and
`config/scripts` already imports it, so the derivation is now a test: the family
set the closure reaches must equal the union of the three committed tables.

C2's inheritance check read the object its own table spreads, which cannot
disagree with itself; it now reads C1's file as text. What that does and does
not hold is written down, because a verdict edited inside `c1-page-closure.ts`
is green there either way — C2 inherits whatever C1 commits. The gate's C1 block
gains the run-totals assertion C5 and C2 already had, which is the check that
edit does fail.

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

* ci: run the page-closure family check in the job that installs mobile deps

Its closure half asks `mobileWebAppDependenciesPresent()` first, so outside the
`mobile_web_app` job it skips itself and the precondition it exists to be never
runs. That job sets `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED`, which turns the same
question into a failure.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 15:17:29 -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 211821dc17 feat(mobile): render agent session history from the desktop's bundle (OTA phase C, C5.1) (#21596)
* fix(mobile): refuse a page target the shell will not take instead of opening it here

`useRouteHandoff`'s web sibling answered two things — handed off, or push it
locally — and fell through to the local router for three different reasons. Only
one of them is a page route. An href the protocol's own pattern drops and a shell
that answered no are the page reaching past what this shell can serve, and the
bundle carries every route under `app/h`, so the fallback does not paint
Unmatched: it mounts `session/[worktreeId]` on React Native Web inside the shell.

The outcome is now tri-state. A target outside `pageRoutes` is never pushed
locally; the page stays where it is and names the reason once per client, which
is the bound the other page-side reporters take.

Proved in the render check against the real bundle: with the double granting no
`navigate`, "Back to hosts" left the host route for `/` and painted Unmatched
before this, and now stays put, posts nothing and reports no page fault.

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

* fix(mobile): encode the host id the worktree row's navigation actions build

Both targets this sheet offers interpolated `hostId` raw — the C1.2 class the
C1.8 stack fixed at the route files and `web.tsx`, at the last two sites that
still had it. `useLocalSearchParams` answers the decoded value, so a deep-linked
id carrying `?`, `#` or whitespace stops being one segment.

It matters more from C5.1 on. Inside the page these targets go through
`useRouteHandoff`, which matches the pathname against the shell's `pageRoutes`
before deciding anything, and the id is the segment the pattern is reading.

The worktree id was already encoded at both sites; this makes the host id match,
and the new test pins all four targets rather than only the one that moved.

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

* feat(mobile): render agent session history from the desktop's bundle (OTA phase C, C5.1)

The second page route. `agent-history/[worktreeId].tsx` already shipped in the
bundle with its own chunk, so listing it adds nothing to the download and moves
no route count: the switch is `index.tsx`'s, and the shell still decides, because
a bundle naming a grant this app lacks renders the native panel instead.

Its `.web.tsx` sibling is required for `index.web.tsx`'s reason — the native file
reaches OrcaMobileWebShellView, whose requireNativeViewManager runs at import and
takes the whole bundle down in a browser, since the manifest imports every route.

First route with two dynamic segments, so both are encoded. Grants are `navigate`
and `storage`: a resumed session opens the native session screen, the worktree
list now reaches this screen without leaving the page, and `app/h/_layout.tsx`
reads the app's own sidebar width above every page route.

The panel's router becomes `useRouteHandoff`, which is the seam that tells those
two apart: agent history is a page route and is pushed here, the session screen is
not and goes to the shell.

The three writes a resume makes needed no page-side handling and have none. What
they needed was a test that the descriptor's handling survives the extra hop, so
each is run through the bridge and against the same fake directly and the two
verdicts compared: a refused create raises the host's message, and a lost reply or
a shell disposed mid-flight stays delivery-unknown rather than becoming a failure
a user would retry blindly. No golden covers those three.

The flag census grows its first entry since C1.3, which is what it is for.

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

* test(mobile): pin the agent-history Back button to the shell handoff

C5.1 wired this button by swapping the panel's `useRouter` for `useRouteHandoff`;
nothing else was needed, because `RouteHandoff` is the router's own shape and the
seam's web sibling decides `back`. So this commit is the test that would have
caught the wiring being absent, not a fix.

Red before the merge, green after, on the same four cases: on `e897e8123a`, where
`back` was still expo-router's own spread member, 3 failed and 1 passed — the one
that passed is the local-pop case, which is the branch C2.2 did not change. After
the merge brought in C2.2's `back`, all 4 pass. The pre-merge run named the notify
by its literal `'navigate-back'` because the contract constant did not exist yet;
it is the same string `BRIDGE_NAVIGATE_BACK_NOTIFY` holds, so the two runs asked
the same question.

Both module substitutions are the builder's own rather than conveniences: the web
bundle resolves `route-handoff` and `client-context` to their `.web` siblings, so
mocking each to its sibling gives this screen the module graph it has inside the
page. The frames are read off the port pair's lane rather than off a spy, and one
case asserts a frame crossed at all before either absence is read as an answer.

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

* test(mobile): compare the resume's second write, not only its first

Round 1, finding 1. All three `resumeAiVaultSessionInTerminal` cases settled the
create (`ai-vault-resume-launch.ts:158`), so `terminal.send` had never crossed the
bridge and the half of the resume that types the command into the pane was
uncompared. Three cases now drive both writes: a refused send raises the host's
message, an accepted send reporting `accepted: false` in-band says "Terminal input
is locked", and a send the host takes resolves — the last one being the presence
precondition, since a run that failed at the create would give the same shape of
verdict as one that failed at the send.

Reading `requests[1]` straight after settling the create finds nothing on the
bridged leg: the second write is made only once the first settles, so it is two
more lane round trips away. `nthRequest` waits instead, and says how many it saw
when it gives up, so this cannot pass by proving the opposite of what it says.

The locked reply is `{ send: { accepted: false } }`, not `{ accepted: false }`:
the reader is `reply.send?.accepted !== false` (`review-terminal-reply-schema.ts:65`),
and the flat shape resolves rather than throwing. Written the flat way first, both
legs agreed on "(resolved)", which is the comparison doing its job.

Also finding 1's second half: the file docstring claimed every case runs twice and
differences the verdicts, which was false for the dispose case — a fake RPC client
has no door to shut, so there is no native run to compare against. The docstring
now says so and the case carries the same note.

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

* test(mobile): pin what encoding cannot save about a dot-segment id

Round 1, finding 2. The route's docstring listed `/ ? #` and whitespace and the
encoding test covered five ids of that kind, which together implied encoding makes
any id safe. It does not: `encodeURIComponent('..')` is `'..'`, so the pathname
reaches `BRIDGE_ROUTE_PATHNAME_PATTERN` intact, fails the lookahead that stops a
climb out of `/h/` (`bridge-caps.ts:68`, read through `bridge-envelope.ts:117`),
and the shell answers with `reportShellFailure` — a failure screen where the route
would otherwise have rendered the native panel it already has.

Pinned, not fixed, and the docstring now says which. `app/h/[hostId]/index.tsx`
builds its pathname identically and has the same hole, so this series fixing one
of two call sites would leave the shape behind and stop describing it. The new
case asserts both halves — the segment survives encoding unchanged, and the
pattern refuses the pathname — so a later change that starts encoding dots fails
here and has to say which screen it wants instead.

Characterisation, so it was green on the first run rather than red: the claim is
about behaviour that already ships, and the value is that the refusal is on record.

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

* test(mobile): mount the agent-history route in a real browser

Round 1, finding 3. Nothing rendered this route's real module graph anywhere. The
unit tests mock react-native, safe-area, svg, lucide and the icon assets away —
they have to, since react-native is Flow source vitest cannot parse — so a
component in this closure with no web build would have reached a device before it
reached a test. The render check is the only place the graph meets React Native
Web, and this route was not in it.

Two cases. The first mounts the route from the shell double and reads the screen:
"Agent Session History" and the worktree label the params half carried, no fault,
no console error, no CSP refusal, and the URL the page wrote for itself. That also
proves `init.route.params` end to end on a route that has a dynamic segment too,
which §1 of the design claimed and nothing checked.

The second pins the chunk. C5 is the first series whose success path pulls a
second chunk after the first paint, which on iOS goes through WKURLSchemeHandler
under `script-src 'self'`. The chunk is named from the builder's own route map
rather than guessed from the bytes, and asserted absent from what the first route
loaded, so this says the route came over the wire now.

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

* docs(mobile): say the real lifetime of the route-handoff refusal set

Round 1, finding 4. The comment claimed one line per reason "for the life of one
client", borrowing `createPageDiagnosticReporter`'s bound. The set is built inside
the `useMemo` keyed on `[client, router]`, so it is per hook instance: in practice
the memo is not recomputed, because `useRouter()` is expo-router's module
singleton and the page holds one client, but every screen calling the hook gets
its own set and a reason can be reported once per screen rather than once per
document.

Says that now, and why it is not tightened: a per-module set would outlive the
page's client, which is the lifetime the rest of these reporters are scoped to,
and there is no document-wide reporter to join without reaching into a contract
file the C2 lane owns.

Records the other half of the finding too, which came back confirmed rather than
changed: `console.warn` is right here. It is the vocabulary `page-bootstrap.ts:35`
already writes in, and a `fault` notify would be wrong twice — the shell drops the
generation on a page fault, and a navigation the page declined is not a failure.

Comment only; no behaviour change, 25 navigation tests unchanged and green.

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

* fix(mobile): wrap every router member that takes a target, not three of five

Round 2, finding 1. `...router` hands through everything this file does not name,
and two of the members it did not name take an href: `navigate` and `prefetch`.
`navigate` to a route outside `pageRoutes` went straight to expo-router and pushed
it into this document — the hole the tri-state exists to close, reopened under a
name nobody had looked at. No call site uses it today, which is why it shipped.

`navigate` is now wrapped exactly as `push` is: which of push-or-collapse it does
is a decision about this document's stack, and a target outside this document has
no such stack.

`prefetch` is decided the other way, explicitly. It is the one target-taker that
must never reach the shell: a prefetch is a background load, `navigate` is the
only thing the shell can be told, so handing one over would open a screen nobody
asked for. A route this document serves is prefetched here, which is what the
per-route chunk split makes worth doing; every other one is dropped without a
line, because a warm-up that did not happen is not a failure to report.

The docstring's "four members that can leave this document" is now five wrapped
members and a rule for which is which.

A list would rot, so the pin is derived: `HrefTakingRouterMember` reads the
parameter tuple of every member of `RouteHandoff` and `WRAPPED_HREF_MEMBERS` is
asserted equal to it in both directions. It reads the tuple rather than testing
assignability because `() => void` is assignable to `(href: RouterHref) => void`,
which would make `back`, `dismissAll` and `reload` target-takers and prove
nothing. Checked both ways: dropping `prefetch` from the list fails the compile
with "Type 'HrefTakingRouterMember' does not satisfy the constraint", and the
union resolves to exactly the five, with `back` and `setParams` outside it.

The pin is in the product module because `mobile/tsconfig.json` excludes tests.
The runtime test asserts each wrapped member is not the router's own function and
that `setParams` still is, so a hook that wrapped everything fails too.

Red first: 4 of the new cases fail against the previous file, 33 pass now.

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

* test(mobile): say per hook instance in the title too, not per client

Round 2, finding 2. The source comment was corrected in round 1 and this test's
title was not, so the two disagreed about the bound the refusal set actually has:
the set lives in the `useMemo`, so it is per hook instance, and a title claiming
per client is the stronger promise the code does not make.

Title only; the case and its assertions are unchanged.

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

* docs(mobile): say what the agent-history render case does not cover

Round 2, finding 3. The docstring claimed the case is where the panel's closure
meets React Native Web, which overstates it. The shell double answers no RPC, so
the session scan fails and the panel paints its "Unable to Load" state: the
session list, its rows, the resume button and the scope tabs never render, and a
render-time gap inside any of them would pass this check.

Now says both halves — import-time evaluation of every module in the closure and
the panel's own chrome are covered, the list subtree is not — and names what
covering the rest would take: a double that answers `aiVault.listSessions`, which
is a different instrument and would put domain behaviour in this file.

Text only. This case moves to its own file on the extracted harness after the
merge with #21592; the corrected text travels with it.

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

* fix(mobile): import the handoff module once in its own test

My round-2 fold added `WRAPPED_HREF_MEMBERS` as a second import of
`./route-handoff.web`, which `import(no-duplicates)` fails in the focused-plugins
pass of the changed-code gate. Joined to the existing import below the mocks,
which is where an import of the module under test has to sit in this file.

Found by running the changed-code gate rather than by review: mobile tsc, whole
tree oxlint and the suite were all green with it.

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

* test(mobile): give agent-history its own render file on the extracted harness

The render check gained browser cases from three domain series at once, each
under the `.mjs` cap of 600 counted lines alone and no two of them together: at
39b15e395d the file was 781 raw lines and clean, main's was 786 and clean, and
their merge was 857 raw and 623 counted, which is the CI red on #21596. C1.10
extracted the harness so a domain gets a file instead. This is C5's, and the
render check is back to 639 raw lines and clean.

Five cases. The two that moved — the route mounts and paints, and its chunk is
fetched on navigation — plus three new ones.

Back, twice. With `navigate` granted the page's Back control posts exactly one
`navigate-back` notify and the page does not move; with the grant withheld the
same tap reaches the same handler and posts nothing. The pair is the point: the
document holds the single history entry the entry wrote with `replaceState`, so a
Back this page served itself would also have gone nowhere and looked identical.
This is the first proof of that handoff in a browser rather than against a mocked
router.

And a row. The harness's new `replies` lets the double answer named methods, so
the panel now renders a real session instead of its "Unable to Load" state, which
is the render-time gap the round-2 docstring conceded. Assertions are on the row's
own text and message count, plus the absence of both silent states — the scan
failing, and a session out of scope.

Replies lifted from the corpus, and one of them needed two scenarios. The session
and worktree lists are `aivault-history-screen-listed`'s. Its `status.get` is a
capability list alone, and the first run painted "Update Orca on your computer":
`HostProtocolGate` above every host route reads the same method for fields that
scenario never scripts. The status reply merges those from
`transport-host-status-gates-ready`, and the comment says why two.

`wt-history` is load-bearing, not incidental. The panel opens on the `workspace`
scope and filters by paths from the worktree list, so on any other worktree these
same replies paint "No agent sessions" — green, and proving nothing.

Registered in the `mobile_web_app` job beside the drawer check, which is the job
that makes a missing mobile install fail rather than skip.

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

* fix(mobile): encode the host id at every href the host page builds

Pullfrog on #21596. My earlier commit fixed the two hrefs in the row's navigation
sheet and stopped there; five more sites in the same page interpolate the decoded
id raw — Accounts and Tasks in both header layouts
(`host-screen-header.tsx:188,204,307,318`) and the session target
`openWorktreeSession` builds (`use-host-worktree-actions.ts:192`).

Same C1.2 class. The persisted host store admits any non-empty id and both
`useLocalSearchParams` and the store answer it decoded, so one carrying `/`, `?`,
`#` or whitespace stops being the single segment `matchesRoutePattern` reads.
Inside the page that decides where a tap goes, because the handoff matches the
pathname against the shell's `pageRoutes` before choosing this document or the
native stack.

A census rather than five more assertions: the failure is a habit, not a bug —
each of these was written by copying the one beside it, and the seventh will be
too. It counts `/h/${...}` interpolations across the host page's four source
files and requires `encodeURIComponent` at each, with a presence check so it
cannot pass on an empty list.

Two sites are exempt and stay raw: `use-host-worktree-actions.ts:171` and
`app/h/_layout.tsx:100` compare against a pathname the router answers rather than
building a link, so encoding them would change what a comparison matches instead
of what a tap opens. The exemption is subtracted by count rather than matched
away, so a file that lost its comparison and gained a raw target does not come
out even.

ONE BEHAVIOURAL EDGE, named rather than fixed. `navigateFromHostList` short
-circuits when `pathname` equals the target minus its query. That comparison now
has an encoded target on one side and whatever `usePathname()` answers on the
other, so for a host id that needs encoding the short-circuit stops firing and a
tap on the screen you are already on re-navigates instead of doing nothing. It is
a redundant navigation, not a wrong one, and the guard at :171 is unaffected
because it compares against the same raw form it always did. Left alone because
fixing it means deciding what `usePathname()` returns for an encoded segment,
which is a question worth its own change.

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

* fix(mobile): guard the optional host id the session target encodes

The commit before this one did not typecheck: `useHostWorktreeActions` takes
`hostId` as `string | undefined`, and `encodeURIComponent` does not. I committed
on a green test run without waiting for `tsc`, which is my error and the reason
this is a second commit rather than an amend — the lane forbids rewriting a
commit that exists.

`?? ''` rather than a cast or a non-null assertion. An absent id then builds
`/h//session/...`, an empty segment the shell's own route rule refuses, instead
of the string "undefined", which that rule would accept as a host genuinely named
undefined. Every other member of this hook already guards the same field.

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

* fix(mobile): keep the agent-history route native when the bridge would refuse its id

CodeRabbit on #21596. The persisted host store admits any non-empty id, so `.` or
`..` reaches this route, survives `encodeURIComponent` unchanged, and fails the
bridge's own segment rule. The route handed it over anyway: `bridge-host.ts`
parses the route against `BridgeInitRouteSchema`, drops it to null when it fails,
and the page answers an `init` naming no screen with "Update Orca to open this
workspace". A failure screen, in place of the native panel sitting right behind
this switch.

The route asks the schema first now and stays native when the answer is no, which
is where every route starts. Mirrors C3.1's call for the files routes
(`69e618e19a`), including its reason for using the schema rather than a copy of
its bounds: two spellings of one rule drift, and the half that matters is the
half the page reads.

The pin moves with it. It characterised the refusal before — asserting the
pathname was built and that the pattern rejected it — and now asserts the native
render, for a dot host id and for a dot worktree id, which is the other segment
and was never covered.

`app/h/[hostId]/index.tsx` has the same hole and is not fixed here, as asked: it
builds its pathname the same way and hands it over unchecked. When C3.1 is also
on main the two guards and `mobile-file-shell-route.ts` belong in one module
beside the schema, rather than a third spelling of a one-line call.

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

* fix(mobile): forward navigation options through the wrapped router members

CodeRabbit (major) on #21596. expo-router's `push`, `replace`, `navigate` and
`dismissTo` are `(href, options?)`, and the wrappers took the href alone. A local
push asking for `{ withAnchor: false }` reached the router without it, so inside
the page the router did something other than what the caller wrote — silently,
because dropping an optional argument is not a type error.

Each wrapper forwards both on its local branch now. Nothing in this tree passes
options today, which is why it went unnoticed and exactly why it needed pinning:
the first caller to pass one would have had it dropped without a word.

Options do not cross to the shell, and the docstring says so rather than leaving
it to be discovered. The `navigate` notify carries an href and nothing else, so a
target handed over is opened by the native stack on that stack's own terms. That
is the right shape — the options describe a push inside a document the shell's
target is not in — but it is a loss, and a loss worth naming.

Four existing assertions moved from `toHaveBeenCalledWith(href)` to
`(href, undefined)`. That is what the router now receives when a caller passes
none, and expo-router reads an undefined second argument as absent; the comment
above them says so, so the next reader does not take it for a bug.

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

* docs(mobile): count the wrapped members the way the returned object does

Pullfrog on #21596. The header still described the set as it stood before
`860577cc30`: "the four members that can leave this document", "the three that
carry a target", "the other three". There are six wrapped members now and five
carry a target, so every count in the paragraph was one or two short and a reader
checking the object against the prose would have found neither explained.

Now says six wrapped, five target-takers named and pinned by
`WRAPPED_HREF_MEMBERS`, four decided by the shell's route list, `prefetch` the
fifth and decided differently for a reason the member's own comment gives, and
`back` the sixth carrying no target at all.

Comment only; 36 navigation tests unchanged and green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 05:15:46 -04:00
Jinwoo Hong b7c06900e2 fix(mobile): give reanimated mapper hooks the inputs esbuild never writes (OTA phase C, C1.10) (#21592)
* refactor(mobile-web): extract the page render harness

The shell double, the CSP/bridge constant readers and the bundle server were
private to mobile-web-app-render.test.mjs, so a second check against the same
page had no way to reach them. Moved as-is into a module both can import; the
double also gained a `replies` map so a check can answer one method and leave
the refusal in place for everything else.

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

* fix(mobile): give reanimated mapper hooks a dependency array

The bottom drawer never slid onto the screen in the web shell page: `progress`
animated to 1 and `withTiming` reported finished, but the sheet kept the
translateY of the animation's first frame and sat one viewport below the fold,
with its invisible backdrop swallowing the next touch.

Cause, bisected in the browser: `useAnimatedStyle` reads its mapper inputs from
`updater.__closure` (hook/useAnimatedStyle.js), which only Reanimated's Babel
plugin writes. The page is bundled by esbuild, which runs no Babel, so
`__closure` is undefined; with no dependency array either, `inputs` is empty and
`startMapper` registers a mapper that listens to no shared value. It runs once
and never again. Reanimated does throw for exactly this, but behind `__DEV__`,
which the bundle builds out, so the page reports nothing. The rAF loop stopping
after one write is the observable end of it.

Not a WebKit fault. Headless Chromium parks the sheet the same way
(translateY(843) vs WebKit's translateY(841)), so the earlier
JavaScriptCore-vs-V8 reading does not hold, and the pin added here runs on both
engines rather than on Chromium alone. WebKit is downloaded in the
mobile_web_app job for it.

Every mapper-backed call site takes the same array, not just the drawer's:
RightDrawer and DragReorderList are the same defect on the same bundler.

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

* test(mobile): census the reanimated hooks that need a dependency array

The drawer pin covers MountedBottomDrawer only, and the failure mode is silent:
a new `useAnimatedStyle`, `useAnimatedProps` or `useDerivedValue` without an
array animates once on the phone's native build and freezes in the web page,
with no error on either. Parsed rather than grepped so a call spanning lines,
or one whose second argument is not an array, is still seen.

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

* test(mobile-web): state motion-on as the drawer pin's precondition

Under `prefers-reduced-motion: reduce` Reanimated finishes `withTiming` in one
frame, so a mapper that only ever runs once still writes the final translateY
and the pin goes green on the broken build. Measured: the unfixed bundle under
reduced motion lands at translateY(0) with the sheet on screen in both engines,
which is also what the Android emulator does with animator scale off — the same
single write, not a healthy animation. The context now says no-preference and
the page is asked to confirm it.

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

* test(mobile): census useAnimatedReaction, whose deps are its third argument

Same fallback as the other three (hook/useAnimatedReaction.js:26-34), so the
same silent freeze applies. Its shape is not the same: the array is argument
three, behind `prepare` and `react`, and both callbacks run inside the one
mapper it starts, so both count as updaters. Indexing it like the others would
have read the `react` callback as the array. No call site today; this is the
gate for the first one.

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

* test(mobile): require the dependency array to list every value the updater reads

An array proves a call was written, not that the mapper listens to everything
it reads. On web `inputs` becomes exactly that array
(hook/useAnimatedStyle.js:338-341), so a value read but not listed is a value
the mapper never hears about: the updater stops re-running when only that one
changes. Same freeze as no array at all, in one prop rather than all of them.

Reads only. The first fixture caught this check counting `opacity.value = v` as
a read, which it is not -- a written value is an output, and demanding it in
the array would be noise at every `useAnimatedReaction`. Assignment targets and
increments are excluded; a value both read and written is still required.

Verified against the tree by dropping `translateY` from the bottom drawer's
array, which the census names at mounted-bottom-drawer.tsx:286.

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

* test(mobile): resolve the hook through the file's imports, not by spelling

Matching the callee's text both missed and invented. `useAnimatedStyle as useAS`
and `Reanimated.useAnimatedStyle` are the same hook wearing another name and
went unchecked; a local helper that happens to be called `useDerivedValue` is
not this hook and would have been flagged. Each local name is now resolved
through the file's imports from `react-native-reanimated`, named, aliased or
namespace member.

A second argument that is not a literal array now counts as present rather than
missing: the hook only needs an array to exist, and this file cannot see what a
hoisted `const deps = [...]` holds, so completeness covers literal arrays only.

Resolution can fail closed, which would read exactly like a clean tree, so the
census now asserts it saw the calls before asserting none are missing. Checked
against the tree by dropping `translateX` from RightDrawer's array, which it
names at RightDrawer.tsx:156.

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

* test(mobile-web): select the drawer sheet by name, not by its corner radius

The pin walked up from the handle to the first ancestor with a 16px top radius,
so it found the sheet through a styling token. Change that radius and the pin
reports `sheet: false` -- a red naming the selector rather than the animation it
exists to watch, on a change that broke nothing.

The sheet now says what it is. `testID` on the RN side renders as `data-testid`
on web (react-native-web createDOMProps/index.js:832), which is the one line of
product change this needs.

Re-verified after retargeting: still red on both engines with the dependency
arrays removed (translateY 843.271 chromium, 841.447 webkit), green with them.

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

* docs(mobile-web): say why the motion option must precede navigation

Reviewer follow-up on the reduced-motion guard. The context option and the
`goto` order are both load-bearing, and nothing in the file said so: Reanimated
reads `matchMedia('(prefers-reduced-motion: reduce)')` once into a module-level
const at import (ReducedMotion.js:8-10), so a `page.emulateMedia()` after
navigation would leave the assertion passing over a value already latched true.
Comment only.

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

* test(mobile-web): name the drawer pin's precondition instead of asserting past it

CI's Linux WebKit failed this pin at `matrix(1, 0, 0, 1, 0, 844)` -- exactly the
viewport, the mount-time value, not a first-frame 843.x. Nothing animated there,
so the pin was reporting a parked sheet without being able to say whether the
mapper was subscribed. Two different faults, one message.

`requestAnimationFrame` separates them and sheet writes do not. `withTiming`
schedules a frame per step (valueSetter.js) whether or not a mapper listens, so
frames across the window mean the shared value moved; the assertion now names
that. Counting sheet writes as the precondition inverts the diagnosis: measured
on the broken build, "written more than once" fires first and calls the defect
this pin exists to catch an engine that does not animate.

Sheet writes stay, as a second statement of the subject and as context in the
transform failure, which now reads "1 style write(s) on the sheet across 30
frame(s)" on the broken build.

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

* test(mobile-web): wait for the drawer to arrive, not for a clock

The pin paused a fixed 1s after the sheet opened and then read the transform,
which makes it a race on a loaded runner: a healthy engine that is merely slow
reads as parked, and the red names the transform rather than the wait. It now
waits for the settled transform, times out at 15s, and asserts on whatever it
found either way, so a genuinely parked sheet gives the same red with the
timing assumption removed. On the broken build that red now reads "1 style
write(s) on the sheet across 3635 frame(s)", which says the fault in one line.

Aimed at CI's Linux WebKit red rather than proven against it: eight container
runs on the Playwright Linux image never reproduced that failure. See the
report for what the container did and did not show.

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

* test(mobile-web): stop asserting on the sheet's style-write count

The count cannot carry an assertion in either direction. Measured under
`--cpus=0.35` in Playwright's Linux image, a healthy page starved of frames
reaches translateY(0) in a single write, because `withTiming` covers the whole
180ms in one step when one step is all the frames it gets. "Written more than
once" would have redded that page, which is a CI runner under load -- the exact
situation this pin keeps meeting.

So the transform is the only subject, `requestAnimationFrame` during the window
is the only precondition, and the write count is context in the failure text.

Also worth recording against the CI log: exactly `matrix(1, 0, 0, 1, 0, 844)`
is reproducible here on the broken build, as the single mapper run landing at
progress 0. It is the mapper's signature as much as a dead engine's, so it does
not on its own say which failed -- the frame and write counts now printed
beside it are what separate them.

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

* test(mobile): count a value read under a unary operator as a read

`isWriteTarget` took any prefix-unary parent for a write, so `!hidden.value`,
`-offset.value`, `+x.value` and `~x.value` were dropped from the reads the
dependency array has to list. A style that gates on `!hidden.value` would have
passed the census while its mapper never listened to `hidden` -- the exact
freeze this file exists to catch, hidden by the check meant to catch it.

Only `++` and `--` mutate, so the prefix branch is narrowed to those two.
Postfix needs no narrowing: `++` and `--` are the whole set there.

Red-first with a negation fixture and a unary-minus fixture; the increment
fixture holds the other side, that a value only incremented is still not
required. Found by a review bot on #21592.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 03:47:44 -04:00
Jinwoo Hong 381a3da46f feat(build): Route A, the phone's host routes bundled for the web, dark (OTA phase C, C0.7) (#21449)
* refactor(mobile-web): share the bundle manifest assembly with a second builder

Manifest assembly and the on-disk write move to writeMobileWebBundleTree, and
the helpers the Phase C app builder needs become exports. No behaviour change
to the shipped bootstrap bundle.

The CRLF guard grows two exemptions it needs once it is pointed at mobile/src:
the image and font extensions .gitattributes already pins -text, and the
gitignored webview engine modules the postinstall writes.

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

* feat(mobile): web entry for the host route tree, and its two transport siblings

The entry mounts app/h on react-native-web through expo-router's own ExpoRoot.
It lives inside mobile/ so one React resolves, and supplies RpcClientProvider
itself: the route tree starts below the native root layout that owns it.

route-manifest.ts is a real typed module whose body the builder replaces --
esbuild has no require.context. A virtual specifier would need an ambient
declaration and would leave the entry unchecked.

Two .web.* siblings, both listed with a reason in web-overrides.json: the
transport substitution point (a placeholder client until C0.4 lands
BridgeRpcClient) and the device token store, whose native path imports
expo-secure-store, which is {} on web.

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

* feat(build): build:mobile-web:app, the phone's host routes bundled for the web

Same builder shape as the Phase A bootstrap into a separate out/mobile-web-app,
with the same manifest and the same two-scratch-build determinism check. Dark:
build:mobile-web, packaging and the A2 census are untouched, and C1 is what
flips build:release.

Six shims, each a named Metro or RN Web gap. Images are emitted as same-origin
hashed assets rather than data: URLs, because the shell's CSP sets img-src
'self'; the render check under that exact header is what found it. The script is
referenced root-absolute for the same reason a <base> tag cannot be used: the
document is served at every route depth and base-uri is 'none'.

The budget sits below the contract's per-asset ceiling so growth trips a build
rather than a refused asset on a phone. esbuild splitting does not lower it:
one entry with only static imports emits one chunk (measured).

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

* fix(mobile): let React Native Web paint under the shell CSP

RN Web 0.21.2 injects its stylesheet at runtime with no nonce support, so
style-src 'self' blocks every rule and the page renders unstyled. Measured, not
predicted: the render check serves the document under this exact header and
reported the violation.

'unsafe-inline' is granted to style-src and nothing else. script-src 'self'
holds, which is the directive that decides whether page code can arrive any way
other than as a fetched same-origin script. The test now pins that scoping
rather than rejecting the token everywhere.

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

* ci: prove the Route A app bundle on every PR

A dedicated job, for the same reason the browser provider has one: it needs
mobile/node_modules and a real browser, and the sharded test matrix would pay
for both on every shard. It builds the bundle, verifies it, and runs the
builder, override-census and render suites. It ships nothing.

The mobile_web_app signal is lifted out of should_run the way static_analysis
is. A mobile-only diff is desktop-irrelevant and skips every gated job, and
that is exactly the diff that changes the page this job builds.

Also the C0.6 review follow-up: mobile/package.json and mobile/pnpm-lock.yaml
join the installer cache keys in the two workflows that build an installer off
a hashFiles key, since beforePack requires out/mobile-web and a mobile-only
change must miss those caches rather than reuse a stale build.

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

* test(build): pin the shipped builder against the app builder's own module name

The assertion named a specifier that no longer exists, so it held vacuously.

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

* test(mobile): assert the RN Web style-src grant in the Swift checks

The Swift twin of the Kotlin CSP test still required style-src 'self' and
no unsafe-inline anywhere, so it trapped on the approved grant.

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

* test(build): make the Route A render check name what each route paints

The check asserted only "some html, no errors", which expo-router's Unmatched
screen satisfies: pointing HOST_ROUTE at /zzz/not-a-real-prefix stayed green.
Each route now asserts content only its own component produces, and the
unmatched case asserts the screen positively so the negatives discriminate.

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

* test(build): read the shell CSP past the comments that quote directives

Both constants document themselves with // comments containing quoted
directive text, which the quoted-string scan picked up as directives. One
parser now drops comment lines, and iOS and Android go through it.

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

* feat(build): honour a .web.* route sibling in the app bundle

Routes were imported by absolute path with the extension, so esbuild's
resolveExtensions never applied and a .web.tsx under app/ was dead code the
census still accepted. The manifest now carries a key and a module: the key
stays the native filename so the URL does not move, and the module is the web
sibling when one exists.

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

* test(build): tie each named shim to the esbuild option that implements it

The shim list was asserted against a literal copy of itself, which passes
however the build is configured. Each entry now carries an appliesTo that
reads its own option, checked against the real options object.

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

* chore(build): line up the CRLF exemptions, the budget comment, and the job scope

The builder loads .gif as a file but neither .gitattributes nor the CRLF scan
exempted it, so the blanket eol=lf pin would have rewritten one. A test now
keeps the two lists in step. The Phase C byte budget's comment sat on the
asset count, and a root package.json edit could change build:mobile-web:app
without running the job that proves it.

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

* style(build): satisfy the index-check lint rule in the CSP parser

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

* ci: key the installer caches on the mobile page trees too

beforePack builds the mobile web bundle into the installer. Today those bytes
are Phase A's, which src/** already covers, but once C1 flips the entry to
mobile/app a page-only change would hit a cache holding a stale installer.

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

* test(build): skip the bundling tests where mobile dependencies are absent

The sharded `test` job collects config/scripts/**/*.test.mjs and installs no
mobile dependencies, so the two new suites failed there on "Could not resolve
react-native-web". They now skip themselves with a message naming the job that
runs them, and that job sets ORCA_MOBILE_WEB_APP_DEPS_REQUIRED so a missing
install fails it instead of skipping everything it exists to prove.

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

* fix(build): scan mobile/packages in the .web.* census

The census claimed the app entry never resolves into packages/, but the
dictation hook imports @orca/expo-two-way-audio and the built script carries
ExpoTwoWayAudioModule.web.ts. That file is now listed with its reason, and
planting a .web.* in each scanned tree proves the scan is not passing because
a tree happens to be empty.

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

* test(build): assert the route exclusions against a tree that has them

mobile/app holds no test, spec or +api file, so the exclusion rule was
asserted against a tree it could not fire on. A scratch tree plants one of
each; dropping the rule now fails this test.

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

* test(build): 404 unknown file paths in the render check's page server

The server answered every path with the document, so pointing publicPath at
/wrong-prefix still rendered three green routes: the script is fetched from
the one prefix that is served. A path naming a file now has to come out of the
bundle, which is what the shell's manifest map does.

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

* test(build): cover the app bundle verifier's own checks

The verifier had no test. One doctors the buildId, which the packaged assert
catches; the other rewrites the tree so every digest still agrees and only the
two fresh builds can tell, which is what a stale out/ looks like. Deleting
either check now fails a test.

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

* chore(build): tidy the app bundle comments and the job's path prefixes

Drops an export nothing read, merges two comments that had drifted apart from
the constant they describe, and corrects the claim that the job runs on every
PR when it is path-gated. package.json leaves the prefix list because
GLOBAL_FORCE_FILES already forces every job on it; mobile/packages/ joins it,
since the page resolves a .web.ts out of there.

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

* style(build): merge the duplicate node:fs/promises import in the census

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

* fix(mobile): redirect the hybrid shell route on the web page

app/h/[hostId]/web.tsx reaches OrcaMobileWebShellView, whose module calls
requireNativeViewManager at import. In a browser that throws before React
mounts, and the route manifest imports every route statically, so one native
route left the whole page blank at every URL.

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

* test(build): fail the render check with the error that stopped the mount

The check waited on "#root has children" with Playwright's animation-frame
polling, so a route module that threw at import read as a bare 30s timeout
naming nothing. It now waits on a mount attribute the entry sets after the
router commits, polls on a timer, and races the wait against the first
uncaught error so the failure carries it.

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

* test(build): answer the favicon the render browser asks for

CI resolves the runner's Google Chrome, which requests /favicon.ico; the
bundled headless shell does not. The bundle carries no icon, so the server
answers 204 rather than turning a browser habit into a console error the
render assertions read as a page fault.

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

* test(build): settle the render check's uncaught-error race without rejecting

The entry throws during goto, before anything awaits the race, so a rejected
promise surfaced as an unhandled rejection beside the real failure. The same
signal now resolves with the error.

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

* chore(mobile): list the page transport in the raw request port inventory

The placeholder client implements the port, so the boundary test counts it as
an unlisted file. It belongs under OWNERS until C0.4's BridgeRpcClient
replaces it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 09:50:37 -04:00
Jinwoo Hong ca2ae89011 ci: install mobile dependencies in every desktop packaging job, pin mobile page source to LF (OTA phase C, C0.6) (#21425)
* chore(mobile): pin mobile page source to LF so a Windows checkout keeps buildId

The Phase C web bundle hashes every text byte under mobile/src and mobile/app into
its asset digests and from there into buildId. There is no global text=auto, so a
CRLF checkout on Windows would give the Windows release a different buildId for
identical source, the same failure the src/mobile-web pin above exists for.

All 1924 tracked files in those directories are already LF in the index, so the
pin renormalises nothing. mobile/web-entry does not exist yet; the pin is
forward-looking for the Phase C entry point.

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

* ci: install mobile dependencies in every desktop packaging job

Ten workflows reach build:release/build:desktop and none of them installs
mobile/node_modules. Root has no react-native, react-native-web or expo, so once
the mobile web bundle builds from mobile/ its packaging jobs would fail at
electron-builder's beforePack with an unresolvable import.

Extract the frozen mobile install that pr.yml's static analysis job already ran
inline into .github/actions/install-mobile-dependencies, and invoke it from every
job the packaging census enumerates, after the root install and before the build.
Same --frozen-lockfile, same lockfile-drift guard, and still no --ignore-scripts:
mobile's postinstall generates the gitignored webview engine modules that tracked
source imports. pr.yml now uses the action too, so there is one definition.

Where a packaging job's setup-node caches the pnpm store, mobile/pnpm-lock.yaml
joins cache-dependency-path so a mobile lockfile change invalidates it. Two jobs
(daemon-relocation-spike, win-update-survival-e2e) do not cache at all and are
left alone.

The census test grows a per-job assertion that the action is present, so a new
packaging job has to add the install deliberately rather than discover it at
beforePack. release-cut's composite-action restore is no longer Windows-only:
every platform consumes this action now, so any of them can be the leg whose cut
ref predates it.

No job builds anything different; this only makes mobile/node_modules present.

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

* test(ci): assert the mobile install contract on the shared action

pr.yml's static analysis job no longer carries the install inline, so the scope
test's findIndex by step name resolved to -1. Match the step by the action it
uses, and read working-directory and --frozen-lockfile off the action itself so
the job cannot keep the step while the action stops installing anything.

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

* chore(mobile): exempt binary asset types from the mobile LF pin

/mobile/{src,app,web-entry}/** text eol=lf would mark a future PNG or font as
text and rewrite its bytes on a Windows checkout. Exempt the asset types an RN
page carries, the same way src/mobile-web exempts its PNG.

-text after text eol=lf wins: probed a CRLF-bearing .png under the pin, it stays
i/crlf attr/-text while a sibling .ts still normalises to i/lf. No tracked file
changes classification; the 1924 files under mobile/src and mobile/app stay i/lf.

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

* ci: gate the mobile install with the build it feeds in the cached lanes

win-crash-survival, win-update-survival and daemon-relocation-spike all skip
electron-builder on an installer/unpacked cache hit, so an unconditional mobile
install spent time on node_modules nothing then consumed. Move each `uses:`
below its cache step and carry the same cache-hit condition as the build.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 05:44:11 -04:00
Neil 5c2d3322c1 fix(runtime): name a terminal whose pane a graph republish dropped (#19860)
* fix(runtime): name a terminal whose pane the graph dropped

`buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement
with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose
`paneKey` still parses to its own `tabId` passes that forever, including long
after the session graph dropped the pane, so a terminal that had lost its
surface reported `orphaned: false, connected: true, writable: true` and a
`tabId` no tab has: field-for-field identical to a healthy one (#18191).

Consult the leaf topology instead, gated on a graph statement having had the
standing to contradict the record. `graphSequence` counts authoritative graph
statements; every statement re-records the surface of every pane it publishes,
so a pane the current graph holds carries the current stamp and is answered
without touching the leaf map. That covers the two absences that are not
evidence, without a second flag: a surface recorded since the last statement
(spawn records the pane before the graph carrying it arrives, #7587), and a
lost graph clearing every leaf at once without advancing the sequence. A pane
already observed dropped keeps its stale stamp and stays named, because losing
the ability to re-check is not a reason to un-see it.

`orphaned: true` is shipped vocabulary that both consumers already read, so no
capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and
now reaches this population, and the duplicate-surface index
(`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's
live owner.

* fix(runtime): publish a terminal retirement proof on the exit's own evidence

A paired client may drop a mirrored terminal on exactly two kinds of host
evidence: a `retiredTerminalSurfaces` proof naming the handle, or two
authoritative `terminal.list` inventories that omit it. The second needs two
host publications, and a quiet workspace publishes one, so the proof is the
only evidence that rides the frame carrying the retraction.

That proof was minted only as a byproduct of persistence *accepting a change*,
which made one value carry two meanings: "a change was accepted" and "the PTY
exited". The host renderer's close transaction de-persists the surface and
republishes without it, so when it got there first the exit found nothing left
to accept and the attestation died with it. Measured on a real paired client:
the host retracted in under 500ms, published no proof, then froze its
snapshotVersion for 60s while the client kept a dead pane in its tab bar.

Persistence still gates *removal* — publishing absence before the membership
fence is durable would let a crash resurrect the surface. It no longer gates
the proof: the observed exit is itself the attestation.

The exit-first ordering already had a passing test; the renderer-first ordering
had none, and that is the one users hit. Both orderings are now pinned, with
exit-first as the control that makes the renderer-first failures mean something.

Wire: `retiredTerminalSurfaces` is an existing optional field on an existing
path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is
Rule 1 — an old client that ignores it degrades to the two-inventory route it
already uses today, so no capability gate is needed. The sentence "the host
starts sending a frame it did not send before" reads like Rule 3; it is not,
because the frame shape, the field, and the reader contract are all unchanged.

* test(runtime): pin the removal frame retiring a still-live publisher

KNOWN RED (`it.fails`), no product change. Found while verifying the close
retraction fix: once the emptying actually reaches paired clients — a state the
previous behaviour never allowed, because nothing propagated — re-adoption of a
later create is flaky. Measured 1 failure in 6 runs of the two-client journey.

`decideWebSessionTabsSnapshot` treats the host's synthetic `removed:<t>`
retraction as a publisher handover: it retires the still-live renderer epoch and
installs the retraction as current, while the removal also clears the live
freshness record. The next frame from that same running publisher then matches
no lineage and reads as a retired generation, so it is outranked and the
publisher is locked out of the worktree until its generation changes.
`local-structured-session-tabs-sync/snapshot-apply.ts` documents this exact
scenario and has a revive escape; the mirror path has none.

The suffix case explains the 1-in-6: `hasRetiredValue` is an exact string match,
so a republication carrying `:headless-merge:` walks past the fence and only a
bare same-epoch republication is locked out.

Not fixed here on purpose. Dropping the retirement makes the red case pass but
breaks `web-session-tabs-sync.test.ts > keeps a removed worktree fenced against
delayed predecessor epochs`, which asserts a same-epoch higher-version frame
after a removal must be rejected. At this layer those are the same frame — this
function holds no `receivedFrame`, so it cannot separate a delayed predecessor
from the live publisher speaking again. The fix belongs in
`shouldApplyRecoveredWebSessionTabsSnapshot`, which does hold that ordering and
currently defers to the same epoch fence. That is a contract change across two
functions and an existing invariant, not a one-liner.

* fix(runtime): a removal retraction is not a publisher handover

The host drops a worktree's entry when its last tab closes and announces it
with a synthetic `removed:<t>` epoch. Both receipt sites treated that as a
publication: `decideWebSessionTabsSnapshot` and
`recordReceivedWebSessionTabsSnapshot` each noted the retraction epoch as
current, which pushed the still-live renderer epoch onto `retired`. The removal
also drops the live freshness record, so the next frame from that same running
publisher matched no lineage, read as a retired generation, and was outranked.
The live publisher was locked out of its own worktree until its generation
changed. That is fail-closed, and it is why re-adoption after an emptying was
flaky once the emptying actually reached paired clients.

A retraction and the live publisher's next frame are the same epoch at a higher
version, so epoch identity cannot separate them and never could. Delivery order
can. `recordReceivedWebSessionTabsRemoval` now records the retraction as the
worktree's newest received evidence instead of deleting the ledger, so
`shouldApplyRecoveredWebSessionTabsSnapshot` — the gate every production apply
path passes before `decideWebSessionTabsSnapshot` — fences a frame that
reserved its received frame before the retraction while admitting one that
arrives after it. The boundary carries the retraction's own epoch, which never
matches a host publication, so a later live frame may still restart its version
counter. `local-structured-session-tabs-sync/snapshot-apply.ts` documents the
same conclusion for the local path: a retired epoch is not proof of a dead
generation.

`keeps a removed worktree fenced against delayed predecessor epochs` pinned the
delayed predecessor at the raw decision layer, which is the same call as the
live publisher's republication. It now pins the identical scenario — same
epoch, higher version, still rejected — through the receive-and-apply path that
actually holds the ordering, plus the composed gate as production spells it.

The committed `it.fails` repro is not sufficient on its own: it records no
received frame, so dropping only the `decideWebSessionTabsSnapshot` retirement
turns it green while the publisher stays locked out on every real path. A
receive-and-apply case is added alongside it to close that gap.

* test(runtime): pin the retraction boundary against a stale inventory omission

Mutation testing left a survivor: writing the boundary unconditionally, instead
of only when it advances the ledger, passed the whole runtime suite. It is not
inert. A visibility-resume inventory reserves its received frame before it
lists, so an omission it reports can be older than a stream frame that landed
meanwhile; without the guard that stale omission rewinds the ledger, forgetting
the stream frame's version, and a delayed list reserved in between is then
readmitted instead of outranked. This pins that ordering.

The one remaining survivor is the boundary's `snapshotVersion`, and it is inert:
the ledger's version is read at exactly two sites, both reachable only when the
incoming frame's epoch equals the stored one, and a retraction epoch never
equals a live publication.

* test(runtime): cover the fences the retraction change narrowed

Two gaps found by mutating the fences themselves rather than the fix.

Deleting the epoch fence in `shouldApplyRecoveredWebSessionTabsSnapshot` passed
the entire runtime suite. It is not unreachable: a superseded generation whose
sibling stream delivers its frame after the handover outranks the successor on
delivery order, and only the retired-epoch check rejects it. Retractions used to
exercise that fence too; now that they no longer retire anything, a genuine
handover is the only thing left that reaches it, and nothing covered that. The
fence is narrower than it was, not dead.

The second case pins rate-independence. The defect surfaced 1 run in 6 because
`hasRetiredValue` is an exact string match while `sameSessionTabsPublicationLineage`
treats `:headless-merge:` as the same publisher, so a merged republication walked
past a fence a bare one hit. The removal path is now asserted over both epoch
shapes through the full path, so a fix that only re-rated the defect instead of
removing it would fail here.

* fix(runtime): give "same publisher" one answer across the epoch fences

Separable from the retraction fix beneath it, and it changes handover-path
behaviour: a superseded generation that republishes under a merged epoch is now
rejected where it was previously accepted. Take it independently or not at all.

`publisher-identity-fences.ts` held two answers to "is this the same publisher".
`noteRetiredValue` treated a `:headless-merge:` epoch as a SUCCESSOR of its base
and retired the base when the merged form became current, while
`sameSessionTabsPublicationLineage` treated the two as ONE publisher. Those are
contradictory, and the retired-value check's exact-string match was the shim
that kept them from ever meeting: a merged frame was a different string, so it
never looked retired no matter what had been retired.

The cost was that the same predecessor was accepted or rejected depending on
which shape it arrived in. A generation a successor had replaced was fenced when
it republished bare and admitted when it republished merged — the fail-open half
of the same disagreement whose fail-closed half was the removal defect, and the
reason that defect reproduced 1 run in 6 rather than every time.

This cannot be fixed in the fence alone. Making the fence lineage-aware while a
merged epoch still retires its base has the generation retire itself: the
rebuild arrives, retires its own base, and the fence then rejects it as a
retired generation. So both sides move together — a lineage sibling advances the
current epoch instead of superseding it, and inherits its generation's
retirement instead of escaping it.

Scoped to the publication-epoch functions. Runtime-id retirement keeps exact
matching, and `local-structured-session-tabs-sync` keeps its own
`hasRetiredValue` call, where a lineage sibling is already excused explicitly
and a retired epoch is deliberately not treated as proof of a dead generation.

* test(e2e): journeys for a reopened client and two clients on one host

Two gaps this suite had no coverage for, both driven end to end against a real
paired desktop client rather than at a seam.

A relaunched client holding a live remote terminal: every paired restart spec
here restarts around a browser pane, none around the terminal the user is
actually mid-work in. The host-side fixture's on-disk sink is the oracle — one
READY for the whole run proves the host never re-spawned the session, and a
recorded line for input sent after the relaunch proves the restored pane is
wired to that same process rather than painted with its scrollback.

Two clients on one host across an emptied workspace: the tombstone is
client-local on the runtime path, so a client that never held a row still seeds
into a workspace another client deliberately emptied. That asymmetry is by
design; a client falling out of step with the host and staying there is not.
Phase 0 is the control — without it a later divergence cannot be attributed to
the emptying rather than to mirroring never having worked.

The input probe goes through `pane.terminal.input`, not `window.api.pty.write`:
a mirrored pane's handle is a `remote:` id that no local PTY answers to, so a
direct write is swallowed and the assertion passes on nothing. The pre-restart
control exists to catch exactly that, and did.

* test(e2e): keep the two-client journey spec type-clean

* test(e2e): pin the close retraction a paired host does not publish

* docs(e2e): say why the red close-retraction spec sits on this PR

The spec was written on a branch carrying neither of this PR's publish-side
fixes, and its own diagnosis -- the fault is the host's publish-after-close,
not any client's mirror -- names exactly what they change. Landing it here
makes CI the measurement rather than leaving a red spec parked on a branch
with no fix in it.

Records the one thing a reader needs to not do: skip-tagging it. And why the
obvious split is not a block move -- phase 2 depends on phase 1b's emptying
and both share the two-client pairing fixture, so splitting means duplicating
the fixture.

* test(e2e): the close-retraction spec is green on this branch, measured

It was written to pin a defect and was red where it was written. On this
branch, with `publish a terminal retirement proof on the exit's own evidence`
and `a removal retraction is not a publisher handover` both present, it passes
-- twice, independently: phase1a A=9ms/B=158ms then A=2ms/B=1ms, against a
prior baseline of "none reached either client within 90 seconds".

So the KNOWN RED header had become the thing it warned about: a test carrying
prose asserting the very behaviour the commits beside it remove. Rewritten to
record the measurement and the numbers to regress against, and to keep the one
instruction that still applies -- if it reddens again, do not skip-tag it; the
failure shape is a 90s timeout on both clients at once while creates still
propagate.

No assertion changed. Comment only.

* test(wire): pair the session-tabs retirement proof across two builds

The stack makes a host start sending a retirement proof on its own frame
when no surface removal carries one. The change argues Rule 1; Rule 3's
fourth bullet covers a frame the host starts sending on an existing path,
so the claim is measured against v1.4.199 rather than accepted.

Neither existing cross-version suite reaches session-tabs: the terminal
one covers the binary stream, the agent-session one covers agentSession.*.

Result: the old client acts on the proof-only frame, because the whole
client half of this surface is unchanged. The old-host cells are pinned
to a release that cannot publish the frame at all, which is what makes
the new-host cells mean something.

* fix(lint): clear the casting gate on the surface-lost inventory

main tightened typescript/consistent-type-assertions to assertionStyle:
never, which the rebase brings onto these added lines. The retraction
read narrows on the property instead of casting; the fixture and
cross-build-import casts carry per-site SAFETY rationales.

* fix(lint): bind the protected-stamp cast to a name

The leading-semicolon parenthesised call put the suppression on a line
oxfmt then reflowed away from the assertion it covers. Naming the
narrowed handle keeps the directive next to the cast.

* fix(runtime): route every non-null surface write through the stamped writer

`ptyHoldsRecordedSurface` trusts a record only while its stamp is current;
after that the leaf map answers. Four writers still named a pane with a bare
`tabId = / paneKey =` — orphan adoption (both branches), split, create on an
adopted stable pane, and TUI-owner recovery — so a record that had already
been contradicted stayed contradicted after the claim, and `terminal list`
reported the just-claimed PTY `orphaned: true` until the renderer's next
graph statement re-recorded it. Before this branch those sites read as
attached at once, so this was a regression window of one round-trip, and
`indexLiveTerminalSurfaceOwners` reads `orphaned` as "unowned".

`recordPtySurface` is now the one writer; the adoption module reaches it
through a port because it has no `graphSequence` of its own. The nulling
writers are untouched: a null surface is never held, stamped or not.

* test(runtime): keep one copy of each publisher-fence case

The removed-frame suite asserted four properties that another case in the
same suite or the lineage suite already pinned:

- the decide-only readmit and the bare full-path readmit are the bare arm of
  the parameterized full-path readmit, verbatim;
- the merged-suffix decide-only readmit is the merged arm of the same loop;
- "still fences a predecessor a successor replaced" is the lineage suite's
  bare arm with different version numbers;
- the recovery-gate handover case is the lineage suite's recovery-gate case
  with a bare late frame instead of a merged one, so that test now runs both
  shapes and this copy goes.

Mutation-checked: reverting each of the five renderer changes on this branch
(retire-on-removal in decide, noting a retraction current, the exact-match
retired fence, merge-supersedes-base, dropping the ledger on removal) still
fails at least one of the remaining ten cases.

Also corrects the suite header: a retraction carries a synthetic `removed:`
epoch, so it is the in-flight predecessor frame, not the retraction, that
shares the live publisher's epoch and needs delivery order to be separated.

* test(e2e): fail the two-client journey when phase 1a cannot run

Phase 1a sat inside `if (beforePartialClose.length > 1)`. A host workspace
that starts with one terminal skipped the control silently while 1b and 2
still ran, and the spec passed green without ever exercising the
close-with-others-open retraction it was written to measure. The skip is now
a recorded failure naming the host count.

* fix(runtime): order every session-tabs apply path against the retraction

A closed terminal came back on the other client because "this worktree was
retracted" was neither durable nor universal:

- `refreshWebRuntimeSessionTabsSnapshot` reached `decide` with no place in
  receipt order at all, so a list the host answered before the close applied
  after the retraction had already cleared the worktree. It is a production
  path for close, create, activation, split and PTY reconnect.
- the boundary lived in a single receipt slot the next stream frame overwrote,
  and in a fence that only existed when a recovery happened to be pending when
  the retraction landed, so a pre-close list could out-rank the republication
  on `snapshotVersion` alone.

Replace both with one raise-only removal watermark per (environment, worktree)
and give the list path a receipt position, reserved by the request and carried
in its answer so a dedupe joiner inherits it rather than minting a newer one.
The pending-recovery fence and its bookkeeping are dead once the boundary is
monotonic. The exact-match retirement check in the receipt ledger becomes the
one lineage-aware predicate, so a `:headless-merge:` rebuild can no longer be
noted as current and retire the live publisher out of its own worktree.

On the main side, `recordPtyWorktree` stamped `surfaceRecordedAtGraphSequence`
at write time, so any `paneKey` write claimed the standing of a fresh graph
statement. The inventory restore in `terminal list` therefore un-dropped the
very pane the read was meant to report, on every listing. A surface claim now
carries no graph standing unless its writer names one: the graph statement,
live leaf output and spawn do, while the inventory restore, the floating
liveness restore and the mobile projection replay do not. Defaulting this way
means a writer that says nothing fails safe and self-corrects, which the type
alone could not guarantee across the projection contract's own `recordPty`.

Spawn claims now span the one graph statement the renderer may already have in
flight, and retirement proofs compare by identity instead of by position, so a
re-delivered exit no longer fans out a `snapshotVersion` bump carrying nothing.

* fix(runtime): stop an unpublished-worktree placeholder retiring the live publisher

A worktree the host has published nothing for still answers a forced list, with
a synthesized `none`/v0 frame that means "ask me later"
(host-session-snapshot-authority.ts). Every post-close list and every
activation of an emptied worktree gets one. Noting it as a publication retired
the renderer generation that is still live, and because that epoch is
per-process, the terminal the user created next never reached this client — the
same lockout the retraction path was already careful to avoid, through a door
it did not cover. `local-structured-session-tabs-sync` already skips the
placeholder for this exact reason; the web mirror now does too, on both the
receipt ledger and the frame decision.

Bound the receipt ledgers by frame age rather than entry count. One bootstrap
inventory records a receipt per worktree under a single reserved frame, so
evicting by insertion order dropped that batch's own earlier entries, and an
absent receipt is what the recovery gate reads as "no evidence for this
worktree". Only a receipt no in-flight frame can still be ranked against is
droppable.

Take the receipt gate off the `web-session-tabs-sync` barrel in the refresh
path. Ordering is that path's gate, not an optional collaborator a caller's
module mock may leave out, and being reachable only through the barrel is how
the path came to have no ordering at all.

* fix(runtime): let the TUI-owner recovery name its pane without claiming the graph holds it

`recoverStructuredTuiOwner` rebinds a recovered PTY from the persisted owner
binding — the same replayed-evidence class as the inventory restore — but
stamped it with the current graph sequence, so a pane the renderer had already
dropped read as attached for one more statement. The guard below it needs the
tabId and paneKey, not the standing.

Also say plainly in `decideWebSessionTabsSnapshot` what the affirms check does
and does not cover: an unpublished-worktree placeholder is withheld from epoch
noting only. It still applies, because rejecting it outright would drop the
terminal reconciliation that legitimately rides on it.

* fix(runtime): keep the retraction boundary out of the receipt bound

Bounding the removal watermark alongside the receipt ledger reintroduced the
defect the watermark exists to prevent: past 512 retracted worktrees, evicting
a boundary readmits every pre-close frame it was fencing, and a delayed list
resurrects the closed tab. A boundary is not a cache. One number per worktree
ever retracted on an environment is the cheaper price, and environment teardown
drains it; only the receipt ledger stays bounded, by frame age.

Split the orphan-adoption port by provenance so the last writer that disagreed
with the surface-standing rule stops disagreeing. `adoptRuntimeTerminalOrphans`
replays the persisted binding when the claim already matches it and writes a
new one otherwise, and both went through a single `recordSurface` that stamped
the current graph sequence — so re-adopting an already-adopted orphan lifted a
dropped pane's stale stamp and reported it attached, in a quiet workspace
possibly forever. The replay now names the pane without standing and the fresh
claim takes spawn standing, like every other writer.

Replace a receipt-count assertion that was vacuous for a map keyed by
environment and worktree with the mirror state and freshness it was standing in
for.

* fix(runtime): keep a closed-tab worktree under the epoch already publishing it

`closeHeadlessMobileTerminalTab` minted `headless:<now>` on every close. Its
sibling headless writers carry the stored `publicationEpoch` forward and mint
only when there is no snapshot to inherit from — because a write to a worktree
is not a claim to publish it. The close was the one writer that claimed.

A paired client retires the epoch a new publisher displaces, and the web
mirror's retirement is final: there is no revive lane, and the per-worktree
tracking teardown deliberately keeps the epoch history. So an ordinary close
published a stranger for a worktree the renderer generation still owned, retired
that generation on every client, and the renderer's next publication — carrying
the epoch the close had just retired — was rejected forever. The user emptied a
workspace, created a terminal, and it never arrived on either machine while
`session.tabs.list` showed the host holding it.

This is the same thesis the retraction path already states, through the door
next to it: a retraction is not a handover, and neither is a close.

Measured on `paired-two-client-emptied-workspace-reseed.spec.ts`, six runs each:
phase 2 failed 3/6 before (`A=null B=null`, both clients blind for the full 30s
budget) and 0/6 after, with both clients adopting in single-digit milliseconds.

* fix(lint): give the fixtures real types instead of casting past them

The casting gate failed on eight assertions this branch added. All eight were
suppressible, but the suppressions were not the problem: the casts were hiding
fixtures that did not match the contracts they stood in for.

`sessionStillHoldingBothPanes` built tabs as `{id, title, type}` — `type` is not
a `TerminalTab` field and eight required ones were missing — and layouts holding
only `ptyIdsByLeafId`. `as never` made both compile. They are now real
`TerminalTab` / `TerminalLayoutSnapshot` values, so the fixture is checked
against the type `listTerminals` actually reads.

`terminalTab` in the epoch suite built a *client* tab (`status`, `terminal`) for
a field typed with *snapshot* tabs, which forced `as never` at the call and a
cast on the snapshot itself. Production reads only `type`, `parentTabId`,
`leafId`, `ptyId` and `parentLayout` from that tab, so the two client-only
fields were inert; dropping them lets the declared
`RuntimeMobileSessionTerminalTab` type the fixture end to end, and the closed tab
is now held by name rather than recovered from `snapshot.tabs[0]`.

The remaining three casts are unchanged in kind and now carry correctly placed
SAFETY rationales: reaching a protected member is the only way to drive these
paths. `graphSequence` folds into the reach-through that was already there
rather than opening a second one, and the map read narrows instead of asserting.

Mutation-tested, all three suites, regression re-introduced for each:
- epoch mint on close restored -> 1 failed | 1 passed
- orphan check reverted to self-consistency -> 4 failed | 4 passed
- placeholder retirement guard removed -> 1 failed | 7 passed

src/main/runtime 8169 passed | 31 skipped; src/renderer/src/runtime 1581 passed.
`check:code-quality:changed` goes 8 findings -> 0. `pnpm tc` clean.

* fix(runtime): stop the headless placeholder graph from dropping every restored pane

A headless server publishes one empty graph at launch so status clients see a
ready server. It names no renderer pane and is never replaced, but it was
counted as an authoritative graph statement all the same: `graphSequence` went
0 -> 1 while the leaf map stayed empty for the life of the process.

Every surface claim written without standing - a persisted replay, an inventory
restore, the TUI-owner recovery - is stamped 0. Against `graphSequence` 1 the
`>=` guard fails, the empty leaf map answers "no pane holds this", and the
terminal reports `orphaned: true` under a `pty:` tabId. Nothing can re-stamp it,
because the only graph that host will ever publish has already been published.
On a headless or SSH host that is permanent, and it is the same lie #18191 is
about, pointed the other way.

The placeholder no longer spends a graph statement. A renderer graph still does,
so a pane a real graph drops is still reported dropped - including on a desktop
window promoted from headless, which the third case pins as a negative control.

Mutation: restoring the unconditional bump fails the first two cases
("expected 1 to be +0", "expected true to be false"); the promoted-window
control passes either way, as a control should.

Also registers tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
in the cross-version-wire job. The file matches CROSS_VERSION_WIRE_PREFIXES, so
adding it had switched the job's gate on, but the job runs an explicit file list
that omitted it - the test executed nowhere in CI. It passes 8/8.
2026-09-18 01:56:07 -07:00
Neil 9d1826ae65 fix(session): repoint the rows a worktree re-key strands (latent; producer is flag-disabled) (#20057)
* fix(session): keep a renamed worktree's rows from matching on the id it lost

Three persisted session fields survived a worktree re-key still naming the old
identity. Two of them are suppression records, so a stale id does not read as
residue -- it silently re-admits state the user removed:

- closedTerminalTabTombstonesByTabId: the remote merge only suppresses a host
  tab when the tombstone's worktree equals the tab's, and no snapshot ever
  covers the old id, so the tombstone never retires either.
- clientHostedBrowserCloseIntentsByEnvironment: the replay targets the intent's
  worktree, and an unresolvable selector answers selector_not_found -- which the
  replay reads as definitively gone and uses to DROP the intent.
- clientHostedBrowserPagesByWorktree: keyed by worktree and re-checked against
  the row's own workspaceId, so both halves have to move or the pages are never
  rehydrated.

Fixed on both sides of the rename: the main-process persisted migration and the
renderer's live store, which would otherwise write the stale values straight
back. The coverage test drives off WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND,
the census these three fell out of, with the shipping owner collector as its
oracle.

* docs(session): record why a re-key clobbering an existing target stays unfixed

Not a missing guard -- an unresolvable one. Keeping the target is correct when
it holds a real closed-last-terminal tombstone; keeping the source is correct
when the target row is a stub; nothing records which is newer. The recency map
is the only one that can settle it, because Math.max needs no such ordering.

* test(persistence): measure the downgrade direction for worktree identity

The stack widens migrateWorktreeIdentity to repoint worktreeId inside
session rows. That changes what lands on disk with no wire change, which
is Rule 3's shape applied to persistence, so it is measured against
v1.4.199 rather than reasoned about.

Result: new-build state does not break the old build. The old build
renames over it without throwing and loses no row; the two row kinds it
cannot repoint stay stale, which is exactly what its own renames already
produce.

The numbers are measured. A first draft asserted the old build repointed
no inner rows at all; it repoints two of four, and the probe is what
caught that.

* test(ci): run the worktree-identity downgrade lane instead of describing it

The cross-version job names its files explicitly, so a new one is inert until
it is listed; the sharded unit job excludes the whole directory and the E2E
router only takes `*.spec.ts`. Also pairs the forward-compat case against the
current build — the stack's own field-list walk is the guarantee that matters,
and only the frozen build was exercised.

* refactor(session): drop the type assertions the rename migration leaned on

`consistent-type-assertions` landed on main after this branch last built, and
three of the `as never` fixtures were hiding real contract drift: a browser
workspace row missing six required fields, a tab group naming three fields the
type does not have while omitting the two it requires, and a sleeping-agent row
whose `providerSession` had neither `key` nor `id` and whose `state` was not in
`AgentStatusState`.

Indexing the session by a computed field name is what forced the casts in the
migration, so the four row maps are now spelled out; the census test is what
keeps a fifth from joining silently. The renderer test builds its state from
the real slice instead of casting a four-field partial.

* refactor(test): name the module namespace the skew harness reads

`object` is too broad for the anti-slop gate, and the import helper already
declares what it hands back.

* docs(test): say which maps the harness actually supplies

The two under test live in slices this harness does not mount, so calling it
"the real slice's state" overclaimed.
2026-09-18 01:11:03 -07:00
Jinwoo Hong ad4f26cdd4 feat(build): build, verify and package the mobile web bundle with every desktop release (OTA phase A, 2/5) (#21326)
* feat(mobile-web): add the Phase A bootstrap web source

A peer of src/ so the root workspace owns it and mobile's separate lockfile
stays out of packaging. Four assets across four content types, enough to
exercise multi-asset manifest handling rather than assume it.

The page reads buildId from manifest.json at runtime: buildId hashes the asset
list that index.html belongs to, so injecting it into a hashed asset would make
that asset's hash depend on itself.

Registered as a fourth typecheck project; without it the entry would be the
only TypeScript in a release path that tsc never sees.

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

* feat(build): build and verify the mobile web bundle from the root workspace

Root esbuild over mobile-web/ into out/mobile-web/, content-addressed as
assets/<sha256>.<ext> with index.html the only stable name. buildId is the
sha256 of the canonical serialization of the sorted asset list, so it is a pure
function of content and usable as a cache key with no further reasoning.

The verifier builds twice into scratch dirs and compares: a timestamp, an
absolute path, or an unstable ordering fails the build when someone introduces
it, not the first time a phone gets a spurious cache miss. It also enforces the
Phase A budget of 16 assets and 256 KiB, separate from the permanent contract
ceiling.

build:release does not call build:desktop, so build:mobile-web is wired into
build:desktop, build:release, and build:release:parallel.

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

* feat(packaging): fail the release when the mobile web bundle is missing or stale

electron-builder only warns about a missing input, so without a beforePack
guard a release ships an app that advertises the bundle capability and then
errors on every request. The hash check, not the existence check, is what
catches a half-written or stale out/.

The source tree is excluded from app.asar; out/mobile-web ships inside it under
the existing out rules, exactly as out/web does.

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

* refactor(mobile-web): narrow the manifest with `in` instead of a cast

The changed-code casting gate rejects assertions, and `in` narrows the same
untrusted JSON without one.

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

* fix(mobile-web): move the bundle source under src/ so the root guard passes

.github/scripts/check-root-directory-entries.mjs blocks any new top-level entry
by name, so mobile-web/ could not live at the root.

The source is excluded from app.asar by the existing '!src{,/**/*}' rule; the
explicit '!src/mobile-web{,/**/*}' entry stays as a marker. out/mobile-web is
unaffected and still ships under the out rules like out/web. No tsconfig
includes src/**, so node, web, cli, and relay do not pick the tree up; it is
registered as a knip entry so audit:dead-code does not call it unused.

buildId is unchanged at 9d78435e: the builder hashes content, not paths.

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

* fix(build): resolve the entry-script guard through pathToFileURL

`file://${process.argv[1]}` never equals import.meta.url on Windows, where that
url is file:///C:/... So the builder exited 0 having written nothing and the
Windows packaging job failed later, at the guard, with no clue why. Every other
script in config/scripts already uses pathToFileURL; this one now does too, via
an exported predicate a posix runner can exercise with a win32 path.

The verify script had no entry guard at all, so importing its budget constants
ran the whole verification — including its process.exit — inside the test
worker. It is now a function behind the same guard.

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

* fix(ci): build the mobile web bundle in the PR package job

That job assembles packaging inputs step by step instead of calling
build:release, so the new beforePack guard hard-failed it.

The census test added here is the oracle: it walks every workflow job that
invokes electron-builder without --prepackaged (which short-circuits doPack
before beforePack) and requires a bundle-producing script in the same job. It
goes red on exactly pr.yml's package job when this step is removed. Ten jobs
covered; the other nine already ran build:release, build:release:parallel, or
build:desktop.

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

* fix(mobile-web): pin source line endings, because CRLF changes the buildId

Every text byte under src/mobile-web is hashed into an asset digest and from
there into buildId, so a CRLF checkout produces a different bundle id for the
same commit: 91af2897 instead of 9d78435e. That would make a Windows-built
desktop disagree with a mac-built one about which bundle a phone has cached.

.gitattributes pins eol=lf for the text sources and -text for the PNG, matching
the four trees already pinned for byte-hashing. The verify script asserts no
source file carries a CR, so the build fails if the pin ever stops applying
rather than silently shipping a second bundle identity.

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

* style(build): read the test's own path from import.meta.filename

oxlint unicorn/prefer-import-meta-properties.

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

* fix(test): census packaging jobs over raw workflow text, not re-serialized YAML

yaml.stringify folds long lines, and in dev-channel-win-build.yml's build-win the
fold landed between `electron-builder` and `--config`, so a real packaging job was
invisible to the census: 11 jobs exist, the test saw 10. Slice each job's raw source
by its parsed boundaries instead, and pin the inventory so a new packaging workflow
has to be added here on purpose.

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

* test(build): assert the script chain the packaging census trusts

The census only checks that a packaging job invokes one of ten build scripts; that
those scripts still reach build:mobile-web was asserted nowhere, so a dropped link
would leave every job looking covered while packaging failed at beforePack. Resolve
each script for real, and pin pr.yml's hand-rolled step, since that job never calls
build:release.

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

* fix(build): realpath the entry path before the direct-invocation compare

Node resolves symlinks in import.meta.url but not in argv[1], so `node /tmp/...`
against a /private/tmp realpath compared two different strings: the builder and the
verifier exited 0 having written and checked nothing. Same silent-success shape as
the Windows file:// bug, so the fix sits next to it, with both seams injectable.

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

* style(mobile-web): format bootstrap.css with oxfmt

It was the only tracked CSS failing oxfmt --check. The buildId is unchanged at
9d78435e8bb73c3341f833c20aaefbd7bfdfc414b68dadf87c1689d86728fe33, because esbuild's
CSS minifier normalises the whitespace this touches before the asset is hashed.

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

* fix(packaging): reject bundle files the manifest does not list

The guard only walked the manifest, so a dropped assets/stale.js passed: assets are
content-addressed, nothing ever overwrites a stale copy, and it would ship inside
asar unreachable and unverified. Require every file under out/mobile-web to be the
manifest or a listed asset.

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

* fix(packaging): give beforePack an explicit mobile web bundle root

The bundle guard read the repo's out/mobile-web unconditionally, so the two
arch-aware packaging tests that call the real beforePack went red in the unit-test
job, which never runs build:mobile-web. beforePack now takes the bundle root as a
second parameter defaulting to out/mobile-web, which is what electron-builder gets,
and those tests build a real bundle into a temp dir instead. The guard is neither
skipped nor made tolerant of a missing bundle.

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

* fix(packaging): census sees script-wrapped packers; dev verify reuses the guard

The workflow census only matched a literal `electron-builder --config` line, so
daemon-relocation-spike's `pnpm run build:unpack` (which packs and runs beforePack) was
invisible to it. Jobs now count when any `pnpm run <script>` they invoke chains to
electron-builder without --prepackaged; the spike joins the pinned list (12 jobs).

verify-mobile-web-bundle.mjs re-implemented a weaker subset of the packaging guard
(no safe-path check, no buildId recompute). It now calls assertMobileWebBundleBuilt, so a
manifest edited after the build fails at `pnpm build:mobile-web` exactly as at beforePack.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 22:41:13 -04:00
Neil ea01cd0ccd fix(windows): reject a node-pty addon that predates the MSYS breakaway denial (#20047)
* docs(windows): record the measured MSYS job-breakaway mechanism

The per-PTY job already denies JOB_OBJECT_LIMIT_BREAKAWAY_OK for Cygwin/MSYS
shells (#19068), but nothing records why, and a conpty.node built before that
commit fails windows-msys-job.win32.test.ts in a way that reads as a source
defect. Measured on a real Windows 11 host: both the plain and the exec-
replacement Git Bash shapes leak, the escape is the MSYS runtime's own
spawn/exec (fork keeps membership), and a single-variable A/B on
usesCygwinRuntime flips the result 0/2 -> 4/4.

Also names the gap the failure hid behind: node-pty-job-ownership.cjs asserts
symbol presence, which cannot distinguish patch revisions.

* fix(windows): reject a node-pty addon that predates the MSYS breakaway denial

The native-runtime gate asserted only that terminateJob, listJobProcessIds and
assignCurrentProcessToJob were exported. All three predate the Cygwin/MSYS
breakaway denial, so an addon built before it passes every gate,
isPtyJobOwnershipAvailable() returns true, and windows-pty-job.win32.test.ts
passes 6/6 -- while every Git Bash child is created outside its pane's job and
survives terminatePtyJob.

Read the resolved .node and require the wide msys-2.0.dll literal that
usesCygwinRuntime holds, the way stagedRelayAddonIsUnpatched() already tells a
patched windows-process-tree addon from a published one. An addon the caller
cannot name is refused rather than skipped: a gate that cannot see its subject
is not a gate.

Verified against real binaries on a Windows 11 host: the shared checkout's
pre-#19068 build errors, a build from current patched source passes, a missing
path errors.

Also closes the cross-host packaging skip. The export half has to load the
addon so it cannot run when the packaging host is not the target, which is how
a Windows release built elsewhere could ship this. The marker is a file read
and needs neither; an unrecognised layout warns rather than fails a release
that was packaging fine.

* fix(windows): check the MSYS breakaway denial on the rebuild path too

The Electron probe carried the marker check, but it lives inside
probeElectronNativeModules, which returns early whenever the Electron package
binary is unusable. Covered by another path is not this path checks -- and the
defect this whole change closes was a gate that looked like it checked.

Reading the binary needs neither a loadable Electron nor an executable target
arch, so assert it after the rebuild, beside the windows-process-tree
assertion that exists for the same reason: this is the addon copied into the
packaged app. Absent warns (a cross-platform rebuild need not leave a win32
addon on this disk); present and unmarked is fatal.

The fixtures now write a real addon file, because the gate reads the binary it
was told about rather than trusting the exports. Verified against the two real
binaries measured on the Windows host: the pre-#19068 build fails this path,
the build from current patched source passes.

* fix(windows): check the marker on every ConPTY path the packaged app can load

The packaged marker check read one hard-coded path, `build/Release/conpty.node`,
and warned when it was absent. `loadNativeModule` tries `build/Release`, then
`build/Debug`, then `prebuilds/win32-<arch>`, swallowing each failure, and
`prunePackagedNodePty` drops the published prebuild only when a same-arch
`build/Release` exists to replace it. So the two packages the check was added for
were the two it could not see:

- cross-host: no host but Windows can build conpty.node, so there is no
  `build/Release` and the prebuild is what ships. The check warned and returned.
- cross-arch: `build/Release` is the packaging host's own arch, patched and
  marked, so the check printed OK -- while the target app cannot load it and
  falls through to the unmarked prebuild underneath.

Measured, not assumed: both published Windows prebuilds in the node-pty tarball
contain neither `msys-2.0.dll` nor `cygwin1.dll` in any encoding. They are the
binary that leaks every MSYS pane child out of its job.

It now sweeps every candidate present for the *target* arch and refuses a package
with no candidate at all, which is a package with no ConPTY backend rather than a
layout to shrug at. It runs for every Windows slice instead of only the branch
the export check skips, so deleting the export check cannot silently take it too.
A stale source build keeps the rebuild advice; the prebuild gets the advice that
actually works, which is to package the slice on a Windows host of that arch.

Also: the marker constant was re-typed in four places and was tied to the C++
literal that produces it by nothing at all, so editing the patch would have left
a gate that fails every correctly rebuilt addon and tells the developer to do the
one thing that cannot help. The fixtures now take the constant from the gate, and
a test asserts the patch still adds `L"msys-2.0.dll"` to conpty.cc.

And the rebuild path treated a missing addon as a warning even on the host that
will run the install, where node-pty would fall through to that same prebuild.
The verdict is now a value, so it is tested without a platform gate.

* fix(windows): resolve the packaged ConPTY the way its loader does

Sweeping every candidate and demanding the marker on all of them was wrong in
the one case it was meant to make safe. `beforeBuild` runs
`rebuild-native-deps.mjs --platform=win32 --arch=<target>`, so a cross-arch slice
normally does get a patched `build/Release` for the target; `prunePackagedNodePty`
keeps the prebuild anyway because its guard is `electronArch === process.arch`
rather than the arch of the binary. That package is correct and its leftover
prebuild is never reached, and the sweep failed it -- telling whoever ran it to
package on a Windows arm64 host, which is both the wrong remedy and one no runner
here can offer.

Presence cannot separate that package from the one whose cross-arch rebuild
quietly emitted the host's architecture, because the only difference is the arch
of `build/Release`. So the gate now resolves the addon the way `loadNativeModule`
does -- first candidate whose PE `IMAGE_FILE_HEADER.Machine` matches the target,
walking root-then-lib for each layout in node-pty's own order -- and checks the
marker on the one that will actually run. A package with no candidate, or none of
the target's architecture, is refused: it has no ConPTY backend either way, and
the second is exactly what a silently host-arch cross-build looks like.

The PE machine reader already existed, privately, in the relay addon builder that
needed the same "a cross-build cannot silently emit host arch" guarantee. It is
now shared rather than copied.

Two seams were unreachable from anything but Windows, so nothing tested them:

- the afterPack hook's win32 block was an inline if/else that only a source-text
  assertion could inspect, and that assertion could not tell the difference
  between the check running and the check being wrapped in `try {} catch {}`. It
  is now `verifyPackagedWindowsNodePty`, and "the marker check runs even where
  the export check cannot" is four spied assertions instead of a string match.
- the rebuild path's verdict read `process` directly, so the branch that fires
  only on the host being rebuilt for was dead on every other host. It now takes
  the host as arguments, and the fs checks, the warning and the failure are all
  exercised from macOS.

Fixtures write a real PE header rather than `MZ fake addon`, since the gate now
reads one. The machine table is pinned to the documented IMAGE_FILE_MACHINE
values, because every fixture builds its header from that table and a table wrong
in both entries would otherwise agree with itself.

* fix(windows): say why the packaged ConPTY fell back, not just that it did

The previous commit resolved the addon by architecture but still had one message
for every way the resolution could land on the published prebuild. Those ways
want opposite remedies, and the one it printed was the remedy the commit before
it had just called wrong:

- no source build in the package at all — the slice has to be built somewhere
  that can build node-pty for the target arch.
- a source build that is there but is the packaging host's architecture, because
  the cross-arch rebuild did not honour `--arch` — re-running that rebuild is the
  fix, and "package on a Windows arm64 host" is neither necessary nor possible.

The second is the common one, since node-pty publishes a prebuild for both
Windows arches and prune keeps the target's on every cross-arch package. So the
old text fired mostly on the case it described least. It now reports which source
builds were skipped and the machine field each carried, and names the rebuild
command.

"Nothing the target can load" had the same problem in reverse: a zero-length or
truncated `conpty.node` got a cross-architecture diagnosis. Every candidate is
now named with what was actually read, including "not a PE image".

The rebuild path asserts the architecture too. A rebuild that ignored `--arch`
was otherwise only visible at packaging, two steps from the command that fixes
it. Arches with no known machine value are left unjudged rather than guessed at.

Two things the extraction broke or nearly broke, both found by mutation:

- the shared PE reader answers `null` where the relay builder's private copy
  returned a number, which would have turned its "node-gyp ignored --arch" error
  into a `TypeError`. Both callers now go through `describePeMachine`.
- the rebuild fixtures stage a script's co-located modules by walking its
  imports, and the walker only understood `from '...'` — so the gate's new
  `require('./windows-pe-machine.cjs')` was left behind and every subprocess test
  failed with a resolution error, which is the exact failure its own comment
  warns about. It now follows `require` and bare side-effect `import` as well,
  and has tests; the fixture stages the gate by walking it rather than by naming
  one file.

Fixtures write real PE headers through one shared builder instead of three
hand-rolled ones.

* fix(windows): run the node-pty addon gates on the Windows job that can

`rebuild-native-deps-node-pty.test.mjs` carries four `skipIf(platform !== 'win32')`
tests. The full suite runs on ubuntu, and the Windows PR job runs an explicit
file list that never named this file -- so those tests were skipped on Linux and
never reached anywhere else. Three of them predate this branch. The Windows job
is added the four node-pty addon suites plus the module-walker one; the comment
above that list already says why it is the right place, which is that the addon
assertions only hold once natives have been rebuilt. Running the path-joining
suites there also covers the separator this gate's candidate list is built from.

The rest is round-three review:

- the rebuild-time arch assertion told a reader "node-gyp did not honour --arch"
  about a file that was not a PE image at all, which is a truncated or
  quarantined artifact and a different command to run. The two now read
  differently, and neither claims the other's cause. Same fix the packaged gate
  had one commit ago, in the place that had not had it yet.
- the missing-addon error said node-pty "would load" a prebuild without checking
  it is there. It says "fall through to" now, which is true either way.
- `isLoadableByArch` had no caller left once the packaged gate started needing
  the raw machine field for its message. Removed rather than kept warm.
- each candidate's header is read once instead of up to three times.
- the module walker's comment claimed every shape that reaches a co-located
  module; it does not follow `projectRequire`/`requireLocal`, and it must not --
  those specifiers resolve against the project root, so following one stages the
  wrong path and the copy fails. Proven by trying: widening the pattern to
  require-shaped names broke nine tests on
  `projectRequire('./config/scripts/...')`. The comment now says what it follows
  and why it stops there.
- a new test resolved a file URL with `.pathname`, which keeps the drive-letter
  slash on Windows -- the very job this commit adds it to.

* docs(windows): put the superseded export-only gate in the past tense

It describes what used to pass a broken addon, so present tense reads as a
description of the gate the same document then explains replacing it.

* fix(windows): repair what running the node-pty suites on Windows exposed

Putting these files on the Windows job turned four assertions red on the first
run. Three of them were in tests that carried `skipIf(platform !== 'win32')` and
had therefore never executed anywhere, on any branch.

- `writeFakeElectronRebuild` emitted the `windows-process-tree` addon a real
  rebuild leaves but never node-pty's, so every Windows test of the rebuild path
  ran against a tree no real rebuild can produce: node-pty "rebuilt" with nothing
  in `build/Release`. The new same-host check reads that state correctly and said
  so. The fake rebuild now writes `build/Release/conpty.node` when it was asked
  to rebuild node-pty for win32, with the marker and the target machine.
- `mkTempProject` never staged `windows-process-tree-creation-time.cjs`. The
  rebuild script reaches it through `projectRequire`, which resolves against the
  project root, so the module walker cannot follow it and must not try. Staged by
  name, with a comment saying which of the two it is. Without it the
  windows-process-tree probe failed to load its own checker and the module joined
  `modulesToRebuild`, which is the second and third red assertion.
- the two `nodePtyAddonPath` cases compared against a literal POSIX string.
  `resolve` returns a drive letter and backslashes on Windows, so they could only
  ever pass off it. Built from segments now, which still pins the `..` traversal
  that is the point of the test.

Verified on macOS: ensure-native-runtime-job-ownership,
verify-packaged-node-pty-job-ownership, windows-pe-machine,
script-module-dependencies, rebuild-native-deps-node-pty, rebuild-native-deps,
rebuild-native-deps-windows-process-tree, ensure-native-runtime -- 109 passed, 6
skipped. The 6 are the Windows-gated rebuild tests, which is the job this change
is aimed at; Windows CI is the arbiter.

* fix(windows): give the packaged fallback a third verdict, for a file that is no image

The packaged gate had two remedies for landing on the published prebuild and
picked between them on `!prebuilt`, which puts a truncated, empty or quarantined
`build/Release/conpty.node` in the cross-arch bucket: "the source build beside it
is the wrong architecture ... re-run with --arch". It is not the wrong
architecture, it is not an architecture, and `--arch` is not the command. The
rebuild-path gate was split for exactly this a commit ago; this is the same split
in the place that had not had it.

Also from review of the settled state:

- the stale-source-build branch ended in a call that happened to throw, so a
  reader could not see it was terminal and the file was read twice to get there.
  The verdict is now an Error the caller throws, built once from the read it
  already did, and shared with `assertCygwinBreakawayDenied` rather than copied.
- four injection seams had no consumer in production or in tests
  (`deniesBreakaway`, `peMachine`, and `exists`/`peMachine` on the rebuild
  verdict). An unused seam is a way for the tested path and the real one to drift
  apart; the tests drive both with real files. Removed.
- the loader table existed in a docblock and in the reference doc, already
  disagreeing about row four. The docblock cites the doc now.
- `peImage` stamped machine `0x0000` for an arch it had no value for, because
  `writeUInt16LE(undefined)` coerces to zero. A fixture that quietly invents the
  field the gates read is the same species of silent lie the gates exist to
  catch; it throws, and a test holds it to that.
- a test named for refusing an unreadable candidate asserted only that something
  threw. Renamed to what it proves.

* fix(windows): make the rebuild fixtures represent a tree that can exist

Second round of what running these suites on Windows exposed. The module the
walker could not stage is now staged, so the probe reached its own checker and
the real reasons surfaced:

- `writeFakeWindowsProcessTree` exported `{}`. The creation-time gate reads
  `supportedProcessDataFlags` off the addon and calls its absence "the tarball
  prebuilt, not a build of the patched source" — correctly. The fixture predates
  that gate and, being Windows-only, never met it. The healthy fake now reports
  the flag, taken from the gate's own constant. Two tests were failing on this,
  the second only because the module then joined `modulesToRebuild`.
- `rebuilds a loadable ConPTY native that lacks Orca job ownership` asked for a
  node-pty rebuild in a tree where node-pty had none of the payload its package
  ships. It gets `writeFakeNodePtyConptyPayload` like its two siblings.

I also tried making the fake rebuild emit `build/Release/conpty.node` the way a
real one does, and backed it out: `restoreNodePtyWindowsConptyRuntime` keys off
that file and then reads `third_party/conpty`, so emitting it in a tree without
the package payload turns one honest gap into an ENOENT two steps away. The
payload fixture is where "node-pty has its addon" belongs.

macOS: ensure-native-runtime-job-ownership, verify-packaged-node-pty-job-ownership,
windows-pe-machine, script-module-dependencies, rebuild-native-deps-node-pty,
rebuild-native-deps, rebuild-native-deps-windows-process-tree,
ensure-native-runtime — 112 passed, 6 skipped. The 6 are the Windows-gated
rebuild tests; Windows CI is the arbiter and is why they are on that job now.

* fix(windows): register the node-pty addon suites in the scope list too

Putting the five suites in the Windows lane's vitest argv gets them run once the
job starts; `WINDOWS_PACKAGE_TESTS` in `pr-code-change-scope.mjs` is what decides
whether the job starts at all. Only the argv was updated, so a PR touching just
`rebuild-native-deps-node-pty.test.mjs` would not have started the Windows job,
and its four Windows-only cases — including the same-host-absent one added here —
would have run on no machine for that PR. Exactly the shape of gap this branch is
about. Both lists now name all five, and `windows-pe-machine`,
`windows-pe-image-fixture` and `script-module-dependencies` join
`NATIVE_RUNTIME_PREFIXES` so a change to the modules themselves starts it too.

`win32-test-lane-registration.test.mjs` exists to catch precisely this and did
not, because its matcher only recognises suite-level gates (`describe.runIf` /
`describe.skipIf`) and a `.win32.` filename. These tests gate per `it`. Widening
it is not this branch's change to make: about thirty files across the repo carry
per-`it` Windows gates and are unregistered, so the ratchet would move far beyond
node-pty. Flagged rather than done.

Message repairs from the same review:

- the non-PE arm of the rebuild-time arch error read "... is not a PE image, so
  nothing can load it, so node-pty would fall back ...". The shared consequence
  clause already opens with ", so".
- the no-source-build packaging error ended "Package this Windows slice on such a
  host", which is wrong advice for the case where the host IS such a host and the
  rebuild simply left nothing — reachable when the artifact is removed before
  prune runs. It now names both readings and points at the beforeBuild output.
- the relay-addon builder blamed `--arch` for a build output that is not a PE at
  all, the same guess the node-pty gate was taught to stop making.
- the patch-drift assertion was a bare `toBe(true)`, so a real drift read as
  "expected false to be true". It now names the two things that can have drifted
  and what happens until they agree.
2026-09-16 22:23:30 -07:00
Neil d62328aa4d fix(codex): remove redundant Windows hook launcher for Unicode profiles (#20952)
* fix(codex): reuse the Windows hook shell for Unicode profile paths

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

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

* test(codex): retry transient Windows teardown locks
2026-09-16 00:30:52 -07:00
Neil 11180fa532 chore(lint): add anti-slop oxlint plugin (pinned, all rules off) (#20726)
* chore(lint): add anti-slop oxlint plugin (all rules off)

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

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

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

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

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

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

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

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

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

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

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

Also makes audit:anti-slop sync the plugin itself before linting. The generated
.anti-slop-plugin/ directory is gitignored and otherwise only created by
postinstall, so a cached install that skips postinstall would leave oxlint
unable to load the plugin.
2026-09-14 21:42:37 -07:00
Neil b61a2347b9 feat(design-system): gate renderer UI with @shadcn/lint (#20731)
* feat(design-system): gate renderer UI with @shadcn/lint

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

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

Fixes the three live bugs the linter found:

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

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

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

* fix(notebook): keep markdown theme selectors working
2026-09-14 17:52:21 -07:00
Neil 20794ee785 ci: keep the baseline build off the compatibility matrix lanes (#20733)
The compatibility gate started the pinned 2.25.5 source build inside the same
step that runs the three measured lanes, so `make -j$(nproc)` competed with two
container lanes whose wall clock is container starts, not Git. A boundary case
that costs ~1.5s stretched past Vitest's 30s timeout and failed the job.

Build the binary in its own step before the matrix, and pull both images before
any lane starts so a lazy pull cannot stall whichever test its sibling is timing.
2026-09-14 17:32:33 -07:00
Neil e86cba888b build: reduce native dependency installs to the host platform (#20420)
* Reduce native dependency installs to the host platform

* Remove install policy documentation

* Guard cross-arch packaging and scope release installs to the runner

electron-builder only logs a warning for a missing extraResources source,
so a host-only install silently shipped a foreign-arch slice without its
natives — `pnpm build:mac` on Apple Silicon produced an x64 DMG with no
sherpa-onnx-darwin-x64 and no @parcel/watcher-darwin-x64. The previous
beforePack hook covered only win32.

- Add assertPackagedNativeVariantsInstalled, an arch-aware check over the
  target's sherpa-onnx, @parcel/watcher, and (on Windows) node-gyp addons.
  beforePack now runs it for every platform, with remedies split: another
  architecture comes from install:release, the os:win32 addons need a
  Windows host.
- Drop --os from the release installs. Every packaging job already runs on
  a runner whose OS matches its target, so only the macOS lanes need extra
  breadth, and only on CPU for their x64+arm64 config. Windows and Linux
  packaging return to a plain host-only install.
- Add --frozen-lockfile to install:release so a bare run cannot rewrite
  the lockfile.
- Restore the install policy reference doc and the CONTRIBUTING note, plus
  the rationale comments dropped from the runtime contract test.
- Gate the packaging-closure assertions on whether the Windows addons are
  installed rather than on the host OS, so a cross-arch install exercises
  them off Windows too.
- Make the workflow contract test read `run:` steps as well as retry-action
  commands, and enforce host-only scoping on the non-macOS packaging lanes.
- Remove the unreferenced install measurement script; its numbers live in
  the policy doc.

* Track the install policy doc and index it from AGENTS.md

docs/** is ignored behind a per-file allow-list, so the new reference doc
was only committed via git add -f and future edits would be skipped. Add
it to the allow-list and give it an AGENTS.md entry like every other
tracked reference doc, so the host-only install rule is discoverable
before someone packages a second architecture.

* Route Windows-lane removals through the retrying helper

Adding these four specs to the PR Windows lane pulled them into the
windows-lane-tree-removal-boundary ratchet, which failed on 20 raw
recursive removals. On Windows a bare rmSync races a handle the OS has
not released, throwing EPERM after the assertions already passed and
reporting a green test as a lane failure.

* Adapt the packaging guard to the vendored Windows registry addon

main vendored windows-native-registry as the workspace package
@orca/windows-registry (#20438). A workspace link resolves on every
host, so including it in the installed-Windows-addons checks proved
nothing. @vscode/windows-process-tree is the only os: win32 npm addon
left, so it alone decides whether the win32 resource plan resolves.
2026-09-12 21:25:03 -07:00
Neil bd0f8826ea fix(ci): match the truncated windows-process-tree virtual store dir (#20447)
On Windows, pnpm shortens the virtual store directory to
@vscode+windows-process-tre_<hash>, cutting into the package name before
the @, so the @vscode+windows-process-tree@* glob matched nothing and the
addon recompiled on every Windows job. node-pty escapes this because its
truncation lands after node-pty@, which the glob still matches.

Widening the prefix to @vscode+windows-process-tre* matches both the full
name kept on macOS/Linux and the truncated Windows one.
2026-09-12 21:11:39 -07:00
Neil 411843f633 fix(ci): cache the vendored addon where node-gyp actually writes it (#20445)
The workspace link means pnpm never creates a .pnpm/@orca+windows-registry@*
entry, so all four native-cache blocks globbed a path that cannot exist and
the addon was recompiled on every Windows job.

Also hardens the addon itself: RegEnumValueW reports a byte count and the
registry does not enforce whole WCHARs for string types, so an odd count let
Napi's auto-length scan run past the value; and a value named __proto__ would
reassign the result object's prototype instead of becoming an entry.
2026-09-12 20:45:42 -07:00
Neil 5127d1eb3b refactor(windows): vendor the registry addon as @orca/windows-registry (#20438)
* refactor(windows): vendor the registry addon as @orca/windows-registry

windows-native-registry@3.2.2 was last published in 2023 by a single
maintainer. Orca called two of its exports, both read-only, so the whole
dependency is replaced by a local N-API addon under native/.

The vendored addon is read-only by construction: setValue, createKey and
deleteKey are gone, so RegDeleteTreeW no longer ships in the app. Two
upstream defects are also fixed rather than carried over — the name/data
scratch buffers were file-scope statics that concurrent reads would
scribble over, and createKey/deleteKey called .c_str() on a temporary.

Build wiring keeps the existing shape: still an optionalDependency gated
to win32, still excluded from pnpm's allowBuilds so only Orca's own
Windows rebuild runs node-gyp for it, still copied into the packaged
resources. The CI native caches now key on the vendored sources so an
addon.cc edit cannot restore a stale .node.

* test(windows): check the vendored registry addon against reg.exe

The addon is vendored source, so no upstream release proves it still
decodes values the way Orca's PATH readers expect. reg.exe is the only
independent oracle on the box.

* ci(windows): register the registry addon test on the Windows runner

A Windows-gated file self-skips on ubuntu, so without both registrations
it reports success while running on no machine at all.

* fix(build): link the registry addon as a workspace package, not file:

As a `file:` dependency pnpm re-resolved and re-linked the package on
every install, including `--frozen-lockfile` (measured: "added 1" on a
repeat no-op install). That virtual-store churn ran concurrently with
node-gyp reading the same tree and cost @vscode/windows-process-tree its
binding.gyp mid-rebuild, failing package (windows) whenever the native
cache hit and only that module needed building. The linux packaging job
hit the same race from the other side, as a pnpm staging move failure.

A workspace link resolves once and leaves the store alone; repeat
installs are now 55ms no-ops. native/windows-registry is listed
explicitly so `packages:` still does not auto-discover mobile/.

* fix(build): stop tracking node-gyp output for the vendored addon

The build/ tree is generated per host and ABI; the committed copy was
macOS-specific gyp scaffolding from a local build and would have shipped
stale Makefiles to every checkout.

* chore: ignore the vendored addon's node-gyp bin output too

node-gyp also emits bin/<platform>-<abi>/ beside build/; both are per-host
generated output that must never be committed.
2026-09-12 20:10:49 -07:00
Neil 7b0701aefa chore: remove 20.7 MiB of duplicate and unused media (#20416)
* chore: remove duplicate and unused documentation media

* chore: guard README local links and refresh tile-01 vendor metadata

- Add config/scripts/check-readme-local-links.mjs: every local src/srcset/href
  in README.md and docs/readme/*.md must resolve to a tracked file. Runs in the
  ungated root_directory_guard job so docs-only diffs (which skip static_analysis)
  still catch a deleted docs-site or feature-wall asset the README embeds.
- Refresh tile-01.recorded-at.json to what vendor-feature-wall-assets.mjs now
  emits for the tab-split source path.
- Drop the pr-19217 evidence prose that cited the removed screenshots.

* fix: accept single-quoted attributes in README local link check

The parser only matched double-quoted src/srcset/href, so <img src='missing.gif'>
was skipped and the guard passed a README that GitHub renders with a broken image.
Regression test fails without the parser change.
2026-09-12 19:40:18 -07:00
Neil 4aa9329e99 ci: narrow pnpm cache keys and shallow development checkouts (#20370)
* ci: cache dependency downloads and shallow development checkouts

* ci: defer mobile caches after measuring restore overhead

* ci: retain existing release signing cache behavior
2026-09-12 01:31:04 -07:00