Commit Graph
11389 Commits
Author SHA1 Message Date
a445abadd4 fix(browser): bound CDP output for stalled clients (#20949)
* fix(browser): bound CDP output for stalled clients

* fix(browser): log CDP outbound overflow before terminating the client

The outbound queue terminated the automation client silently on overflow, so
the client saw a socket close indistinguishable from a crash. Surface the cap
that tripped and the backlog held when it did.

The queue dropped its backlog before invoking onOverflow, so the counters were
already zero at the callback. Snapshot them first and pass them through.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:24:33 -07:00
4d82149fe5 fix(runtime): reject stale inventory after PTY lifecycle changes (#21014)
* fix(runtime): reject provider inventory across PTY lifecycle changes

* fix(runtime): canonicalize SSH inventory generation keys

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:24:29 -07:00
Neil b766f512ec fix(editor): extract diff first-change auto-scroll to a hook to unblock main (#21738) 2026-09-19 17:06:58 -07:00
OrcaWinandm4air b8f67a6266 Close workspace board when selecting sidebar worktree (#21737)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-19 16:54:56 -07:00
Neil 85a3ba6d42 fix(terminal): align CJK IME preedit spacing (#19367)
* fix(terminal): align IME preedit to terminal cell grid

* fix(terminal): preserve native shaping and reuse IME preedit on repaint

* fix(terminal): preserve native shaping with bounded IME spacing runs

* test(terminal): account for inline preedit subpixel rounding

* test(terminal): keep the IME grid fixture wide at every DPI

* chore: regenerate xterm patch after rebase

* test(terminal): remove IME assertion lint findings

* test(terminal): avoid reflective IME fixture access

* test(e2e): run IME renderer matrix with WebGL available

* fix(ci): restore editor line budget
2026-09-19 16:44:38 -07:00
Neil e4c7632db2 perf(terminal): skip kitty scans for plain PTY output (#21643)
* perf(terminal): skip kitty scans for plain output

* fix(terminal): keep the kitty scan fast path total for absent chunks

The new escape-byte fast path dereferences the chunk before the string
concatenation that used to coerce a nullish value, so an unchecked
caller now throws instead of no-opping. Normalize once at the top.

Also type the AgentTerminalPreview connect mock against the real preload
signature, which turns the stale bare-string replay fixture that tripped
this into a compile error.
2026-09-19 16:26:32 -07:00
Neil abd310e5a3 perf(terminal): skip background SGR scans without ESC (#21646)
* perf(terminal): skip background SGR scan without escapes

* perf(terminal): avoid duplicate renderer risk scans

* test(terminal): pin the carried renderer risk scan tail

The foreground renderer-risk scan splices the carried tail onto the
incoming chunk before classifying it, and nothing covered that ordering:
a pre-gate moved back above the concatenation would silently drop the
refresh for a background SGR split across ConPTY chunks.

Also pins the escape-free ASCII path and the shared global SGR pattern's
statelessness across calls, since the background hit returns mid-loop.
2026-09-19 16:25:58 -07:00
3e7da29767 feat(editor): add opt-in collapsed unchanged regions for file diffs (#11955)
* feat(editor): add opt-in collapsed unchanged regions for file diffs

The combined "View All Changes" diff already collapses unchanged lines into
expandable bands (DiffSectionBody sets Monaco's hideUnchangedRegions), but a
single-file diff opened from Source Control renders the whole file. Reviewing
one changed line in a long file means scrolling past everything else.

Adds a General > Editor setting, default off, that applies the same Monaco
option to the single-file diff viewer. Off keeps today's full-file rendering.

The option is always emitted rather than omitted when off: Monaco retains the
last applied value across an options update, so dropping the key would strand
an open diff in collapsed mode after the setting is turned back off.

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

* fix(settings): register collapse unchanged search entry

* fix(editor): keep diff viewer under line limit

* fix(editor): satisfy diff viewer line budget

---------

Co-authored-by: Dan Cieslak <dcieslak19973@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 15:53:28 -07:00
Neil 844e9df98f fix(editor): keep long file paths distinguishable (#21631)
* fix(editor): keep filename visible in long paths

* fix(review): keep diff filenames visible

* fix(review): prevent path metadata overlap
2026-09-19 15:43:45 -07:00
Jinwoo Hong cef4416115 fix(mobile): name the narrow host header's controls and gate the drawer's hardware back on web (OTA phase C, C2.10) (#21729)
* fix(mobile): name the narrow host header's controls

The header renders two toolbars and the phone sees the narrow one,
whose controls carried neither a role nor a name. A screen reader could
not find them, and C2.9's render check could only assert their absence
at 390 px. The wide toolbar already names every control from the same
state, so the fix is to say the same thing rather than invent wording:
filter, sort, group, accounts, tasks and the search toggle take their
wide sibling's role and label expression verbatim.

The census names a seventh site the plan did not: the Reconnect button
in the status bar above both toolbars, which is shared rather than
narrow and has no wide sibling. It announces through its Text child
today, so it takes the role and the string it already renders.

No layout, style, handler or order changed; the diff is accessibility
props only.

The census parses the file with the TypeScript API and rules that every
Pressable carrying an onPress has a button role and a name, and that a
control both toolbars render is named the same way in both. It keys the
pairing on the handler, because that is what makes two elements the
same control, and asserts each shared handler is found exactly twice,
so a control deleted from one toolbar cannot leave the naming rule
comparing a group of one with itself. Red first, naming all seven
sites by path and line.

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

* fix(mobile): stop the right drawer arming hardware back on web

React Native Web logs "BackHandler is not supported on web and should
not be used." and returns an inert subscription, so inside the shell's
page every open of this drawer put that line on the console and armed
nothing. The gate is the one mounted-bottom-drawer and the file preview
already carry, with the same comment stating the degradation: there is
no hardware back in a WebView, and the shell owns the one the phone has.

The drawer had no render test. This one mocks react-native, the safe
area, gesture handler and Reanimated the way the bottom drawer's
hand-back test does, and reads the call rather than the console: on iOS
and on Android the handler is registered once for 'hardwareBackPress'
and released when the drawer hides, and on web it is never reached. The
three native cases are the control that keeps the web case honest; they
passed before the fix, which is what makes the single red meaningful.

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

* fix(mobile): type the right drawer test's element helper

The tests-typecheck ratchet was red on the previous commit: the drawer's
props declare `children` as required, so passing it as createElement's
third argument left no overload matching. It is a prop here, and the
helper answers a ReactElement rather than a return type borrowed from
createElement.

Test files sit outside `tsc --noEmit`, so only the ratchet sees this;
it is the gate that exists because a type-level pin in an unchecked
test proves nothing.

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

* fix(mobile): write the right drawer test in JSX

Lint was red on the previous commit and I ran it in the same command as
the commit, so it landed: passing `children` as a prop to satisfy the
type checker is exactly what react(no-children-prop) refuses. The
canonical form settles both, so the test is JSX in a .tsx file and the
drawer takes its body as a child again. The StyleSheet mock's generic
needs the trailing comma a .tsx file requires.

Re-proved in this form: with the web gate removed the web case fails
and the three native cases still pass.

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

* fix(mobile): derive the header's naming groups, and read a spread as unknown

Round 1, four folds.

The drawer's comment claimed the console line was observed inside the
shell's page. It was not: the drawer's one caller is the review screen,
whose route C4 serves, so no page closure reaches it today. The gate is
pre-emptive and now says so in its own words rather than borrowing the
bottom drawer's sentence.

The naming rule iterated a hand-written list, so it only ever compared
the six controls both toolbars render. Giving the two
`actions.openFloatingWorkspace` sites different labels left the census
green. The groups are derived from the discovered controls now, keyed
by the handler text, so any handler this header presses from more than
one place is compared and the failure prints both names. The declared
list stays as the precondition it always was: each of the six is found
exactly twice, which is what keeps the derived rule from holding
vacuously over a file with no repeated handler.

The scan read `Pressable` only and dropped any control whose `onPress`
read as empty text, which is what a spread reads as. It reads
`TouchableOpacity` too now, and a spread answers unknown rather than
absent: a control whose handler or whose accessibility props arrive
through one is kept, fails both rules, and prints `spread` rather than
`none`, so it can never be mistaken for a control the scan judged.

Red first on all four: the reviewer's disagreeing-label mutation, a
spread over the a11y props, a spread over the handler, an unlabelled
TouchableOpacity, and a shared control deleted from one toolbar.

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

* fix(mobile): read a braced-empty label as unnamed, from one shared reader

Round 2, two folds.

Only a bare `""` or an omitted attribute read as unnamed, so
`accessibilityLabel={undefined}`, `{''}` and an empty template all left
a control with nothing to announce and the census green. Reproduced on
both Tasks sites with each of the three shapes before the fix. The
reader unwraps a braced expression now: a string or a no-substitution
template answers its own text, and the identifier `undefined` answers
empty, so all three read as unnamed.

That reader was a near-verbatim copy in both censuses, which is how one
of them could have gained this rule and the other kept the hole. It
lives in one module under mobile-web-shell now, named for what it reads
and typechecked by mobile tsc rather than by the ratchet alone. Both
censuses import it and neither changed an assertion; their diffs are
the deleted copies and the import.

Red first, five mutations: the three empty shapes on both Tasks sites
here, and `{undefined}` and `{''}` on the tasks Back, which the page
census now catches too and did not before.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 18:33:20 -04:00
Jinjing 93d245e358 Sort dev builds by timestamp instead of semver (#21720)
Dev build base versions can move backwards when a branch is cut before
the latest main build. Their embedded timestamp is the authoritative
"newest" signal for the picker. For dedicated release repos, compare
publishedAt timestamps before falling back to semver comparison.
2026-09-19 15:15:23 -07:00
OrcaWinandm4air 9309350864 fix(chat): enforce legacy import byte budget during reading (#20976)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-19 14:53:31 -07:00
89acf1e1fa fix(plugins): release diagnostic logs after successful uninstall (#21185)
* fix(plugins): retire log owners after successful uninstall

* fix: address memory PR review regressions and withdraw false positives

* fix(plugins): fence stale activation after uninstall

* chore: allow durable plugin uninstall audit evidence

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 14:53:28 -07:00
2741bdad38 fix(session): remember closed and initialized runtime tabs (#21630)
* fix(session): persist defaultTerminalTabsAppliedByWorktreeId

Host persist snapshots wrote tabs but omitted this write-once map, and
full session replaces / hydration treated omission as "never applied".
Union the marker across persist and hydrate so default terminals are not
re-spawned on every launch or re-attach.

Fixes #18117

* fix(runtime): stop re-seeding a runtime-owned workspace the user emptied

Focusing a workspace owned by a remote runtime created a terminal every
time, and sometimes two.

The mirror could never record the closed-last-terminal state. A host
snapshot with no terminals produced `nextTerminalTabs === null`, which
`withWorktreeEntry` turns into a deleted key -- and a missing row is
exactly how every seeder spells "never initialized"
(initial-terminal.ts). Keep an explicit empty row instead, so the remote
path reads the same tombstone the local one already honours. A worktree
that never had a terminal still gets no row, because `sameTerminalTabs`
treats a missing row and an empty one as equal; removal frames and
synthesized unpublished frames keep deleting, since neither is evidence
the user emptied anything.

The duplicate had a second cause. `requestedInitialTerminal` was a `let`
inside the session-tabs subscription closure, so "one focus creates at
most one terminal" held only for as long as that closure lived. Its
effect re-runs whenever the environment, connection generation, pairing
revision, or session-ready flag settles -- all of which move during a
workspace switch -- so a second closure re-armed the flag while the first
create was still in flight. That is the asymmetry in the report: one
terminal when arriving from the landing screen, two when arriving from
another workspace. Latch the bootstrap per worktree in a module-scoped
set instead, modelled on web-runtime-wake-terminal-respawn.ts, released
when the create settles. The closure flag stays alongside it so a failed
create still does not retry on every later frame of the same
subscription.

Fixes STA-6173.

* fix(runtime): harden the runtime-owned initial-terminal bootstrap latch

Follow-up on the STA-6173 fix, addressing restore-time safety gaps found in
review.

- Decline the bootstrap on a synthesized unpublished frame
  (`UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH` at version 0). That frame is the
  runtime saying "ask me later", not a host with zero terminals; seeding on it
  can duplicate a pane the host is about to republish after a restart. This is
  the same "ask me later" frame the tombstone write already refuses to treat as
  the user emptying the workspace.
- Release the module-scoped bootstrap latch on worktree tracking teardown and
  environment teardown, mirroring web-runtime-wake-terminal-respawn. A create
  RPC that never settles during a disconnect would otherwise leave the
  per-worktree key set and suppress the next bootstrap after reconnect.

Tests:
- New per-worktree and per-environment latch-release cases.
- New "unpublished frame declines" bootstrap case.
- New hook-level regression pinning the second defect end to end: a forced
  active-subscription re-run while the first create is in flight seeds exactly
  one terminal (two on the pre-fix tree).

* fix(runtime): key the initial-terminal bootstrap latch per environment

Addresses review on the STA-6173 hardening.

- Key the bootstrap latch by (environment, worktree), not worktree alone. A
  worktree id is `repoId::path` with no host component, so the same id can be
  live on two paired runtimes at once. The latch was cleared wholesale on any
  environment teardown, so tearing down environment A released environment B's
  in-flight key and a fresh B subscription could seed a duplicate — the STA-6173
  defect through another door. Environment teardown now clears only its own
  keys; worktree teardown clears only that (environment, worktree).
- Hold the latch after a successful create until a mirrored `tabsByWorktree` row
  exists. The snapshot refresh the create awaits can resolve on an empty,
  unconfirmed frame that leaves no row; releasing then let a later effect re-run
  seed a second terminal. A failed create still releases for retry. The latch
  claim/create/release now lives in web-runtime-initial-terminal-bootstrap-dispatch.ts,
  keeping active-session-subscription within its line budget.

Tests (each mutation-tested against its own regression):
- Cross-environment: a create for env B in flight, env A torn down, a fresh B
  closure must still decline. Fails when env teardown sweeps all environments.
- Hold-until-row: a create that resolved without mirroring a row must not let an
  effect re-run seed again. Fails on unconditional release.

* fix(runtime): release the bootstrap latch when the create returns failed

createWebRuntimeSessionTerminal never throws: the operation catches RPC and
network failures and returns `{ status: 'failed' }`. The dispatch helper
released the latch only from `catch` (dead for that path) or once a
tabsByWorktree row existed (false after a failure), so a failed create left
the latch held and suppressed every later auto-seed for that environment's
worktree until teardown -- the opposite of its own doc comment.

Capture the outcome and release on a returned failure as well, so the next
focus can retry.

Regression: a create that resolves `{ status: 'failed' }` followed by an
effect re-run must create again. Fails on the previous release condition.

* fix(runtime): release a parked bootstrap on the mirror's next frame

The previous commit released the latch on a returned failure, but a create
that *succeeded* with no mirrored row yet had no release at all: the
row-conditional check was the only exit for the success path, so a host that
accepted the tab while the mirror never got a frame held the latch until
environment teardown and suppressed every later auto-seed for the worktree.

Give the latch two phases. `creating` blocks other closures while the RPC is
in flight. A success with no row is parked as `awaiting-mirror` instead of
held, and the next frame the mirror accepts for that worktree releases it --
that frame is the mirror's answer either way (a row now exists and the
predicate declines on its own, or the host genuinely has no terminal and a
retry is right). A create still in flight keeps its claim: releasing it on a
frame would reopen the re-armed-closure race the latch exists to close.

Also correct the closure-flag comment: `requestedInitialTerminal` is set only
after the dispatch resolves, so a thrown create never sets it and a later
frame may retry. The flag records that this subscription already owned a
create; it never described a failed one.

Regressions, each mutation-tested against its own term:
- success with no row, then the mirror's empty answer, then a fresh closure
  -> must create again (fails when success-with-no-row is held instead of
  parked, and when the subscription does not call the frame release)
- a mirror frame releases an awaiting-mirror claim but never a pending
  create (fails when the release ignores the phase)

* fix(runtime): let a failed create retry inside its own subscription

The returned-failure release freed the module latch but the caller still
latched its closure-local requestedInitialTerminal whenever the dispatch
reported it owned the create, including a create that returned
{ status: 'failed' }. A thrown failure never set it and retried on the
next frame; since every RPC and network failure is reported as a return,
the live path was the one that suppressed the whole subscription. The
dispatch now reports false for a failure whichever way it arrives.

* fix(runtime): close the second re-seed door on an emptied workspace

ensureWebRuntimeWorktreeTerminalAfterWake read tabsByWorktree through
`?? []`, and the only guard that could have seen presence was &&-gated on
the value that erased it, so it could never fire for a tombstone. It runs
on every activateAndRevealWorktree, not only after a wake, and a
tombstoned workspace routes exclusively here: the stream-frame path
returns at localTerminalCount === 0 before reaching its own guard. So the
workspace was re-seeded on every focus regardless of the mirror fix.

Two states shared one line and now do not. With no rows the workspace is
being seeded for the first time and the decision goes to
shouldAutoCreateInitialTerminal with presence read by Object.hasOwn; with
rows present the question is whether a woke workspace's chrome outlived
its PTYs, which the tombstone says nothing about. Both arms are pinned,
because reverting only the respawn arm broke no existing test.

* fix(runtime): stop the worktree-id re-key inventing a tombstone

canonicalizeTerminalSessionWorktreeId read the source row through `?? []`
and always wrote the target one, so a workspace with NO tabsByWorktree row
came out the other side with an explicit empty one. That is the
closed-last-terminal tombstone, and this PR's new activation-path reader
honours it, so the re-keyed workspace never gets its initial terminal.

Guarded on the source row's presence, which is the guard the sibling
keyed maps six lines below already use. Mutating the guard to test
emptiness instead of presence breaks the assertion that a real tombstone
still survives the re-key, which is the distinction that matters.

* fix(runtime): reconcile session tab bootstrap integration

* fix(runtime): preserve terminal wake launch options

* test(session): annotate cross-project persistence fixture

* test(session): remove obsolete typecheck suppression

* test(session): keep persistence regression in renderer project

* refactor(runtime): remove inert snapshot recovery wrapper

* fix(runtime): keep removal-frame import merge-safe

* fix(runtime): deduplicate merged removal-frame import

---------

Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-19 14:30:25 -07:00
Jinwoo Hong 6a0200f416 test(mobile): certify the files page closure, 28 families and 125 goldens (OTA phase C, C3.2) (#21724)
* test(mobile): certify the files page closure, 28 families and 125 goldens

C3.2. The closure is re-derived at this base from the entries esbuild compiles —
the two `.web.tsx` files — and matches the design exactly: 28 families, 125
goldens, C1's 22 a strict subset, 6 families and 22 goldens added. Explorer
3441 modules / 304 local / 10 under `src/files`, preview 3666 / 330 / 19, union
342 local. No `mobileWeb.*` family appears, which is the tell that the native
switch was not measured.

Two tables at the route seam, as C2.6 splits its own: the explorer's one family
and the preview's five are separate evidence for two routes with separate
grants, and `c3-page-closure.ts` only spreads them over C1's.

C1's 22 families are inherited verbatim, not re-derived. Measured here, C2's
rule disagrees with 13 of those 103 pins — all 7 in `tasks.smart-source-search`,
all 5 in `host-worktree-refresh`, and `worktree-catalog-snapshot`. C5's
docstring says 10; corrected in this commit, comment-only. Every family C3
shares with C2 and with C5 is asserted equal object for object, and the three
committed pin files disagree on nothing.

Class totals: identical 66, result-absent-settlement 47, params-undefined 7,
result-absent-stream-release 3, write-ordinal 2. Pinned beside the per-id walk
because a table built wrong in a self-consistent way passes the walk.

Red-first, both halves: changing one verdict fails the totals and the
inheritance check by name; dropping a golden fails drift, totals and the census
count together.

What 125 certified does not say: `host-worktree-refresh`, inherited from C1, has
no byte-identical golden at all, so its 5 hold a class and not bytes. All six
families C3 adds have at least one. No scenario replays a save twice, so
`files.writeTerminalArtifact` is certified for one round trip and not for
idempotency; and no golden here subscribes, because the domain opens no stream.

The `config/scripts` precondition now derives both files routes and compares
their union against the C1 + C3 tables, with a second case proving each route
reaches a strict part of it — without which the union would pass with one route
contributing nothing.

Also folds pullfrog's open nit: the render check's comment claimed
`toContain('readme.md')` proved the encoded round trip, which a truncated path
would also satisfy; the url assertion beside it is what proves it.

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

* test(mobile): share the pin-source reader and finish C5's 10-to-13 correction

Two pullfrog findings on #21724.

`c5-page-closure.test.ts` still said the rule disagrees on 10 of the 103; only
`c5-page-closure.ts` moved in the last commit, so C5's two files disagreed with
each other and with C2's and C3's. The sentence now states 13 with the same
breakdown the other three carry. No assertion depended on the number. While
there, the comment records why C5 compares against the imported object rather
than the committed text and is still sound: it inlines its families instead of
spreading C1's, so there is no spread for an edited entry to launder through.

`pinsFromSource` was byte-for-byte identical in C2's and C3's suites — checked,
not assumed — and is now one module both import. It sits beside
`page-closure.ts` rather than inside it, named for what it reads: the module it
would have joined holds pure table arithmetic and this one reaches the file
system. The C2 suite's cases and assertions are unchanged; its diff is the
deletion of the copy and one import.

The shared reader keeps its teeth: making the wrapped-entry capture unmatchable
reds the inheritance check in both composed suites, which is the defect the
comment describes — three `result-absent-stream-release` pins once went missing
that way with an empty mismatch list.

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

* docs(mobile): name which leg of the path round trip each assertion proves

Round-1 nits 3 and 4. A second commit rather than a fold into `dc1de6e728`:
that one is already committed, and amending is off the table.

The render check's comment claimed the url assertion was the proof of the round
trip. It is the outbound leg only — what the page encoded into its own history —
and a screen that mis-decoded the middle of the path satisfies it and the title
assertion both. The comment says that now, and points at where the inbound leg
is proved instead: `mobile-file-path-route-encoding.test.ts`, which reads each
hazard shape back out of the href, and `mobile-file-preview-route.test.ts`,
which drives the normalizer the screen reads its params through. Both files
checked to exist, and read, before being cited.

The 10-to-13 edit left a 127-character line in a file that wraps at 100;
reflowed. It was the only over-length line the C3.2 commits introduced — the
others in both files predate this branch, and `oxfmt` accepts them because it
does not reflow comments.

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

* docs(mobile): say what the inheritance case is blind to, and finish the reflow

Round-1 item 3, plus a correction to my own claim.

The "inherits C1's families whole" case reads C1's committed text, which makes
it independent of the spread but not of C1's file: edit a verdict there and both
sides of the comparison move together. The docstring now says that, says what it
does catch — a C3 half redeclaring an inherited family, which the spread would
otherwise take silently from the last table — and names where the C1-file edit
is caught instead.

Both measured here rather than taken from the review. Flipping
`settings-repo-metadata-icons` in C1's file leaves this case green and reds
seven others: the class totals and the exclusion counts in C2's suite and this
one, both cross-series agreements, and C5's own inheritance case, which compares
against an independent literal rather than a spread. Redeclaring
`settings.repo-metadata` in the preview half reds this case along with five
more.

I also said last round that the 127-character line was the only over-length line
these commits introduced. That was wrong: I checked the two files in that commit
rather than the branch. Six lines over the 100-column wrap came in across four
files, including two I had just written in `c3-page-closure.test.ts`. All six are
reflowed, and the check is now over every line the branch adds rather than over
the files I happened to touch last.

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

* docs(mobile): state both redeclaration shapes, or neither count

Round 2's low finding. The docstring said redeclaring `settings.repo-metadata`
in the preview half reds this case "along with five more". The reviewer is right
that the number is shape-dependent, and right about both figures.

Re-measured, no truncation this time: one golden under the family's name reds
seven, because it also shrinks the census to 114 and leaves that family with no
byte-identical golden; the family copied verbatim with a single verdict flipped
reds five, the census unmoved at 125. The docstring states both with the reason
they differ, and says plainly that the count is not the claim — the load-bearing
half is that the spread takes the last table's entry and this case is what sees
it, which holds in both shapes.

The six came from reading a `head -6` of the failure list as the whole of it.
That is the same mistake in miniature as the one this file's own comments warn
about: an empty-looking result that was only a truncated one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 17:18:38 -04:00
Jinwoo Hong 335d111265 fix(mobile): name the Back control in every page-served screen (OTA phase C, C2.8) (#21715)
* fix(mobile): name the Back control in every page-served screen

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 16:36:21 -04:00
Neil f016d38e9c test(e2e): prove Pi EOF removes sidebar agent row (#21722) 2026-09-19 13:00:46 -07: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
Jinjing 3cd674fdff Make jump-palette selection state higher contrast (#21718)
- Increase light-mode contrast: foreground mix 12%→13%, ring 18%→19%
- Extend selectors for .jump-palette-command scope and add class
2026-09-19 12:13:42 -07:00
Neil b6b974cb82 fix(terminal): clear stale agent identity after shell exit (#21714)
* fix(terminal): clear stale agent identity after shell exit

* test(identity): update resolver decision table
2026-09-19 12:10:10 -07:00
Neil c4c9486470 fix(pi): do not install extensions for disabled agents (#21711)
* fix(pi): honor disabled agent extensions in PTY setup

* fix(pi): preserve fresh OMP config when disabled
2026-09-19 11:22:56 -07:00
Neil 97763edac0 fix(pi): ignore idle utility dialogs in completion status (#21707) 2026-09-19 10:47:06 -07: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
Neil b0cbb919ba fix(pi): use Pi configured provider for Source Control AI defaults (#21693)
* fix(pi): let Source Control AI use Pi configured default

When Orca runs Pi for automatic branch names or commit messages without an explicit model override, omit --model so Pi resolves its configured provider. Preserve explicit discovered model selection and add regression coverage.

* fix(pi): preserve discovered fallback for non-Pi agents

Keep the configured-default sentinel behavior limited to agents whose default is the explicit CLI sentinel. Other dynamic agents still fall back to the first discovered model when their static default is unavailable.

* test(pi): pin configured-default dry-run arguments

Prove Source Control AI does not render the Pi configured-default sentinel as a literal model argument, and assert explicit model flag pairing positionally.
2026-09-19 10:14:51 -07:00
Jinwoo Hong 3ad6b7e46e chore(mobile): repin the recording corpus to main's tip after #21674 (#21690)
#21674 re-recorded the corpus with `baseline` set to its own branch commit
`1006ad670a`, which the squash merge left unreachable from main. The pin
guard on the push to main fails, and so does the pin job on every open
pull request, since the merge ref cannot reach that commit either.

Repin to main's tip `e8a7be4ce2` and re-record from that tree. Every
golden's body is byte-identical to the one #21674 recorded; only the two
header lines move: `baseline` (788 files) and `lockfileSha256` (778
files), the latter because #21652 changed `mobile/pnpm-lock.yaml` after
that branch recorded.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 12:33:14 -04:00
Neil e8a7be4ce2 fix(omp): recover retired pane status with validated restart authority
Merged after fresh run 35448889017 passed all required checks, including static analysis, typecheck, package jobs, all test shards, changed E2E, Docker SSH E2E, and verify.
2026-09-19 08:05:33 -07:00
Neil e7da72c3d7 fix(omp): attach desktop and mobile images through file mentions
Merge fully verified: desktop/mobile focused suites, node and mobile typechecks, changed-code quality, hosted RPC recording pin, package checks, all test shards, and verify pass. This fixes #20389 across composer, drop, picker, and mobile clipboard-accessory paths.
2026-09-19 07:05:20 -07:00
Neil 3ba7cb4de9 feat(diagnostics): trace terminal startup delivery phases
Merge fully verified: all required CI checks pass. This lands bounded startup timing instrumentation for the open Windows OMP first-paint investigation in #19333; it does not claim the latency fix itself.
2026-09-19 06:58:31 -07:00
Brennan Benson 061a756b84 test(agent-status): pin that omp's approval_mode cannot hide a real prompt (#21499)
omp forwards its `approval_mode` on every `tool_approval_requested`, and the
shared normalizer deliberately ignores it. Nothing recorded why, so the field
reads like a dropped qualifier that a future change should start honouring.

It must not be honoured. Measured against omp 17.0.5: the CLI emits this event
only after its own policy engine already resolved the call to "prompt", and then
parks on a human Approve/Deny select. Auto-approved calls emit nothing at all.
`approval_mode` carries the ambient mode (always-ask | write | yolo), not the
verdict, so a per-tool `tools.approval.<tool>: prompt` produces a genuinely
blocked human carrying `yolo` -- the one value that looks auto-approving.

No behaviour change. Records the reason at the decision site, replaces two
fixtures that asserted an `approval_mode` of 'prompt' (not a member of omp's
enum) with captured values, and adds guards pinning that every real mode,
plus a missing or unrecognised one, stays blocked, and that pi is unaffected.
2026-09-19 06:12:04 -07:00
Neil b0ec11f5b0 fix(omp): redact credential references before status transport (#21673) 2026-09-19 05:58:09 -07:00
NeilandBrennan Benson 5cc89b6406 fix(sidebar): preserve OMP status across sparse split pane ids (#21671)
Use stable PTY-to-leaf bindings when in-session pane closes leave sparse runtime pane ids. Keep the existing parked and dense slot resolution paths, and cover the completed OMP pane/sibling-running case from #15557.

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-09-19 05:57:48 -07:00
Neil bf250b1200 test(omp): prevent duplicate tab on worktree reselect (#21670) 2026-09-19 05:48:35 -07:00
Neil 2e278426ef fix(omp): sweep local agent PTYs during app quit (#21668) 2026-09-19 05:48:32 -07:00
github-actions[bot] 0e90e855db Update README downloads badge 2026-09-19 12:33:03 +00:00
Neil 5cf65d79fc fix(omp): ignore nested task session ownership (#21663) 2026-09-19 05:25:16 -07:00
Neil 6abd1ce53b fix(agents): refresh remote detection on new launch surfaces (#21659) 2026-09-19 05:12:42 -07:00
NeilandSudoAI-DEV ae9c06c941 feat(omp): discover and switch native-chat models on desktop and mobile (#20612)
* fix(omp): discover and switch native-chat models

Report the running OMP provider/model and discover available choices on
the execution host for desktop and mobile. Register an extension command
to switch through the OMP API because its TUI does not accept /model args.
Advertise that command in status so older hosts remain read-only.

Addresses the OMP portion of #17603; Pi chat enablement remains separate.
Model reporting begins on lifecycle activity; no startup status is invented.

Co-authored-by: SudoAI-DEV <220139811+SudoAI-DEV@users.noreply.github.com>

* refactor(omp): check generated model metadata types

* test(omp): verify model picker command and reported selection

* test(omp): add repeatable real model-switch runtime proof

* test(omp): require model capability delivery in runtime smoke

* fix(mobile): decode OMP model discovery through RPC operations

* fix(omp): preserve exact reported model selectors

* fix(omp): preserve generated extension syntax after rebase

* fix(omp): merge generated harness UI context types

* test(omp): model switching keeps one session manager

* test(omp): include transcript path in model status proof

* test(omp): avoid renderer error-type union

* test(omp): keep renderer test type explicit

---------

Co-authored-by: SudoAI-DEV <220139811+SudoAI-DEV@users.noreply.github.com>
2026-09-19 05:11:48 -07:00
Neil eca2ff6730 test(omp): keep transcript smoke resume proof current (#21661) 2026-09-19 05:10:21 -07:00
Jinwoo Hong c22c442fdb feat(mobile): answer native verbs on the shell, clipboard first (OTA phase C, C2.4) (#21623)
* feat(mobile): declare the native verb table and advertise it (OTA phase C, C2.4)

The contract half of the shell-answered request seam: the `native.` prefix, a
typed table with params and result schemas per verb, and the two clipboard
verbs.

`MOBILE_WEB_SHELL_GRANTS` spreads the table's own name tuple rather than
restating it, so a verb cannot be advertised without a row and a row cannot
exist unadvertised — the table is `Record<BridgeNativeVerb, …>`, so a missing
row does not compile, and the suite holds the other direction. Verb names go
in the flat grant list on purpose: a route may declare one, and a shell that
lacks it keeps that route native rather than walling it.

The mime shape admits `image` because a later build will serve one; this one
refuses it, and the reason will say out of scope rather than unsupported,
since `expo-clipboard` implements the image calls.

No frame kind is added and no protocol version moves.

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

* feat(mobile): answer native verbs on the shell and fence them from the desktop (OTA phase C, C2.4)

The host half of the seam. `forward()` is the one place a request reaches the
client, so the `native.` check sits there and nothing about ids, caps,
settlement or cancel moves: a native request takes a pending slot and settles
on the same frames as a forwarded one.

`readBridgeNativeVerbCall` is the whole decision, separate from the host so
the `ungranted` arm can be exercised at all — every page is offered every
verb this build implements, so through a real host that arm is unreachable
today and is the point of the check once a grant is per-route.

Refusals carry `native_verb_refused`, which the desktop's vocabulary does not
contain: an unlisted method comes back from `MOBILE_RPC_METHOD_ALLOWLIST` as
`forbidden`, so reusing that would make a leaked fence read as an ordinary
scope refusal. Every case in the host suite reads `client.requests` for the
same reason.

`_meta` is omitted from host-authored replies per the ruling, which required
making it optional on `RpcSuccess`/`RpcFailure`: the type required a field the
wire never has. `isRpcResponse` does not read it, `runtime-rpc-envelope`
already makes it optional on a failure, and nothing in this app reads it —
every occurrence is a fixture writing one. Zero other type errors resulted.

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

* fix(mobile): restore the harness verb-type import and drop an unused one

Two leftovers from threading the native reply type through and then removing
it: the host harness lost its `BridgeNativeVerb` import, and the request
module kept a type import nothing uses. `tsc` and oxlint both failed on the
previous commit; this is the follow-up rather than an amend.

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

* test(mobile): typecheck the native verb suites and drop the dead reply type

Three leftovers the ratchet caught, none visible to `tsc -p tsconfig.json`,
which excludes test files:

- the fence suite read `frame.payload` off the whole `reply` union, and a
  chunked reply has no `payload`; it narrows on the field now
- the bridge hook's own suite builds its caller options inline and had no
  `serveNativeVerb`
- `BridgeHostAuthoredReply` became unused once `_meta` was optional, and an
  exported type nothing reads is the pattern round 2 of C2.3 flagged; the
  statement it carried already lives in the verb table's header

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

* feat(mobile): give the page a typed surface for the native verbs (OTA phase C, C2.4)

`useNativeVerbs` is the page's side, typed from the same table the host
serves, so a verb cannot be called with params the shell will refuse.

Each call goes out as an ordinary `request` and settles on the ordinary
frames; the method name is the whole difference. A verb the shell did not
grant is refused before a frame is sent, because a rejection after a round
trip and one that never left look identical to an `await` and only the first
costs an in-flight slot — `granted` is exposed so a caller can pick its own
fallback instead.

Results are parsed rather than trusted: the shell is a different build than
the page, and a result shape that moved should fail at the seam rather than
halfway through a screen reading a field that is not there.

No call site uses it yet; the two `Clipboard.setStringAsync` sites are the
consumer PR's.

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

* fix(mobile): send native verbs from the module that owns the request port (OTA phase C, C2.4)

`use-native-verbs.ts` called `client.sendRequest` directly, which the
unvalidated-request-port boundary refuses: new code must send through an
`RpcOperation`, and nothing may be added to the inventory.

An `RpcOperation` is not available to this seam. Its `method` is typed
`RpcMethodName`, which is `keyof typeof RPC_PARAMS_BY_METHOD` from the
desktop's generated params catalog. Putting `native.clipboard.read` there
would declare that the desktop serves a method the whole fence exists to keep
off it.

So the send moves into `bridge-rpc-client.ts`, already listed as an owner of
the port — a module that implements the port rather than a call site picking
its own method and acceptance. `callNativeVerb` rides the same frame, id
space and in-flight cap as any request, and the page surface stays a thin
typed wrapper that reaches no raw port.

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

* fix(mobile): fence native methods on subscribe, not only on request (OTA phase C, C2.4)

The fence sat in `forward()`, which is the one place a *request* reaches the
client. A `subscribe` reaches the same client by another door: a frame naming
`native.clipboard.read` opened a real stream on the desktop, and because
`client.requests` stayed empty the whole suite read as green over it.

Refused in `handleSubscribe` before the id is claimed, under the same
`native_verb_refused` code, so nothing about the frame reaches the desktop or
occupies a slot. Cancel needs no arm of its own: it can only settle an id
that was admitted, and none is.

The oracle is widened with it. Every case now reads the client's streams as
well as its requests, because the old one could not see this at all — an
absence that only ever looked at half the boundary.

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

* fix(mobile): hold a native verb's answer to the result it declares (OTA phase C, C2.4)

The table names a result schema per verb and the host never applied it, so a
handler could answer `{ nonsense: 1 }` and the page's own parse would be the
first to notice — halfway through a screen, not at the seam.

Validated on the host and refused by name on a mismatch, which is what makes
the table's claim true on the side that serves it.

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

* fix(mobile): keep the native verb member from being a raw port (OTA phase C, C2.4)

`callNativeVerb(verb: string, params: unknown)` took any method, so
`callNativeVerb('worktree.list', …)` reached the desktop through the real
pair — a raw request port in the one module allowed to hold one, and invisible
to the inventory, whose scan counts `.sendRequest` shapes and not a bare
call inside the owner.

The parameter is typed `BridgeNativeVerb` now, which is the fence for every
caller the compiler can see, and the prefix is checked at runtime for one
that reached the member through a widened type. The compile-time half is
pinned by a `@ts-expect-error` the tests-typecheck ratchet holds: widening
the parameter back makes that directive unused and fails there.

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

* fix(mobile): give every native verb refusal one typed error at the page (OTA phase C, C2.4)

Only the seam's own refusal carried `native_verb_refused`. A handler that
declined and a reply too large to send arrived as other categories with no
code at all, and the hook rethrew a bare `Error(message)` — so a caller
telling an out-of-scope mime from an unsendable clipboard had to read message
text, and those want different handling.

Three changes, one shape. The host re-raises a handler's failure under the
seam's code, keeping the handler's message because that is what says why.
`BridgeReplyUndeliverableError` carries its frame refusal as a code, so
`reply-too-large` survives to the page. The hook throws `NativeVerbError`
with a `reason` read off the code `reconstructBridgeError` already copies
onto the rejection, plus `ungranted` for the arm this side decides.

Removes the unreachable `ok: false` branch from the hook with it. The
narrowing it was doing moves into the client member, which now promises a
success or a rejection and nothing else.

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

* test(mobile): test the in-flight cap and cancel, not the duplicate-id branch (OTA phase C, C2.4)

The case named for the cap sent the same id twice, so what it exercised was
the already-in-flight check. It never held a second slot and would have
passed against a seam that took none.

It now fills the cap with distinct ids against a handler that never settles,
and asserts the one over it is refused with the cap's own message. A cancel
case goes with it: a native request cancelled before its handler settles
posts nothing afterwards, the way a forwarded one does not answer an
exchange the page has moved on from.

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

* test(mobile): pin the native verb member's type with a directive, not a cast

The case proving a desktop method cannot go through `callNativeVerb` reached
the runtime guard with `as never`, which the casting gate refuses — and a
cast is the wrong tool anyway: it asserts past the very type the case exists
to pin.

`@ts-expect-error` instead, which the tests-typecheck ratchet holds: widening
the parameter back to `string` makes the directive unused and fails there.
The call still runs, so the runtime guard is exercised too.

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

* fix(mobile): parse the shell's error code instead of reaching for it (OTA phase C, C2.4)

The anti-slop audit refuses `Reflect.get`: dynamic input is parsed into a
named shape before it is read. `code` is not a property of `Error` — it is
whatever `reconstructBridgeError` copied onto the rejection from the capture
— so a schema is the honest reader here, and it says what this takes without
asserting the rest away.

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

* fix(mobile): name the id collision before the native fence on subscribe (OTA phase C, C2.4)

The fence ran before the already-in-flight check, so a `subscribe` naming a
`native.` method under a live request's id settled that request with the
fence's message. The page lost the request either way — the collision class
predates this PR — but it was told the wrong cause, which is the difference
between a bug it can see and one it cannot.

Collision first. The fence still runs before any slot is taken, so nothing
about the frame reaches the desktop.

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

* refactor(mobile): parse a native verb result once, inside the catch (OTA phase C, C2.4)

The result was parsed twice: by the send path against the table's schema, and
again at each caller against the concrete one. The second parse was dead, and
it sat outside `call`'s catch, so a shell answering a shape the page did not
expect would have escaped as a bare `ZodError` — the one shape this surface
promises not to throw.

`call` takes the verb's result schema and parses once, inside the catch, so
every failure leaves as a `NativeVerbError`. The params parse at the callers
goes with it; the host validates params and the page builds them typed.

Also moves the comment block documenting `onExternalLink` back above it,
which `serveNativeVerb` had landed in front of.

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

* fix(mobile): give every native verb refusal its own code, and keep handler words on the device (OTA phase C, C2.4)

Two findings that had to land together. Six faults all arrived as
`native_verb_refused` and differed only in message text, which the hook's own
comment said nobody may switch on. And a handler's message crossed verbatim:
a clipboard read that failed after reading is free to put what it read in its
error, and the error frame is the only path out of this seam that is not a
declared result.

So each fault gets a code — unknown verb, ungranted, bad params, wrong
result, out of scope, handler failure, native-on-subscribe — and the three
paths that reached the page uncoded get one too: the in-flight cap, a
non-native method through a widened member, and host disposal. `reason` is
now drawn from a declared list with no `unknown` arm, asserted at the hook.

A handler's code crosses and its message does not; the shell logs the real
one. The out-of-scope mime stays distinguishable because the code carries it,
not the text.

`bridge-host.ts` crossed the line cap with this, so the serving half moves to
`bridge-host-native-verbs.ts` — read the call, serve it, hold the answer to
what the verb declares — leaving the host the frames around it. No cap was
disabled or raised.

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

* fix(mobile): refuse unknown verb params, and floor an unknown code (OTA phase C, C2.4)

Two the bots caught, both about a shape one side does not know.

`z.object` strips unknown keys, so `{ mime, value, unexpected }` dispatched
as if the extra key had not been sent — and the page and the shell are
separate builds, so a param the shell silently ignores is the shape of a verb
that changed underneath a page. `z.strictObject` on the verb params and
results.

And the page passed any code through as `reason`, while its own doc and
`NATIVE_VERB_REASONS` promised a closed list; a shell newer than the page
would have fallen off the end of a caller's switch. Unrecognised codes floor
to `unreported`, `reason` is typed to the list, and the doc says what the list
actually is rather than the single code the per-arm ones replaced.

The flooring is tested by delivering the frame such a shell would send: this
build's host normalises an unknown code before it leaves, so the pair cannot
produce one.

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

* fix(mobile): serve no request before the page has asked for a session (OTA phase C, C2.4)

`serving` starts true so a page's first frames are not refused for arriving in
the same native batch as its `ready`, but nothing checked whether an `init`
had ever been sent. So a request from a document this host had told no caps,
no grants and no route was forwarded to the desktop, or served as a native
verb, while the notify path had refused exactly that since C0.

Gated on `initSent`, under the protocol's own `before-ready` name. Streams are
left alone: the finding names requests, and gating `subscribe` too is a wider
change than it asked for — worth its own decision, since the same hole is
there.

Fourteen host cases and five hook cases were relying on this: they open a
request without ever asking for a session, which no real page does. They take
a `ready` now, through a harness option, and the counts that read what the
host posted account for the `init` a session opens with.

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

* fix(mobile): grant a page session what its route declared, not what the app can do (OTA phase C, C2.4)

`init.grants.native` handed every session the shell's whole capability set,
so a route declaring only `navigate` and `storage` was granted
`native.clipboard.read` as well. That was harmless while every grant was a
navigation or a write the page could make anyway. It stopped being harmless
the moment a verb reads something back, which is this PR.

The session is now granted the intersection of what this shell implements and
what the mounted route declared in `MOBILE_WEB_PAGE_ROUTES`, plus the
protocol's own `fault`. One list: `init` issues it and every grant check —
notify and native verb — reads the same one, so what a page is told it may do
and what it will be served cannot drift.

`MOBILE_WEB_SHELL_GRANTS` and `implementsGrant` are unchanged; the shell's
capability set is still the ceiling a route's list is drawn from.

User-mediated authorization is not attempted here and goes to C2.7 as an open
question.

`use-mobile-web-shell-session.ts` crossed the line cap with the extra field,
so the three effect workers that touch the network and the disk move to
`mobile-web-shell-session-effects.ts`, leaving the hook its reducer and
callbacks. No cap was disabled or raised.

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

* fix(mobile): key the pre-handshake refusal on the session, and apply it to streams (OTA phase C, C2.4)

Two rulings, one mechanism.

The gate keyed on the host instance, and a host is rebuilt whenever the client
under it changes. The page does not know: the session id is the same, so it
neither re-handshakes nor hears that the shell was replaced. So a live page's
next request was refused, and would have been until reload — a regression, not
a safety gain, and not covered by the in-flight settling as delivery-unknown.
The host now inherits whether its session already handshook, which the hook
records when the page first asks.

And the rule is about the session rather than the frame kind, so `subscribe`
is gated with `request`: a stream opened before the handshake was the same
hole.

Fourteen stream cases were exercising a state the protocol forbids — they
subscribe without ever asking for a session, which no page does. Every one is
about caps, backpressure windows, acks, cancel, idempotency or arity; none
was testing anything through the hole itself. They complete the handshake
now, and the counts that read what the host posted account for the `init`.

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

* fix(mobile): judge a cached fallback by its own routes and grants (OTA phase C, C2.4)

The newer manifest is read before the download is attempted, so its
`pageRoutes` and `routeGrants` are already on the session when the download
fails. Opening the cached generation then mounted an older page under a newer
bundle's grants: a cached route that never declared the clipboard would have
been granted it by a manifest it is not running.

The fallback now derives both from `cached.routes`, and applies that
generation's own render eligibility before mounting it — a route only the
newer bundle claims is not a route the cached page can serve.

This is the Phase D "grants across generations" item arriving early. Only the
grant side is fixed here; persisting a generation's grants with the
generation itself stays Phase D's.

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

* fix(mobile): remount the shell on a route change, so its bridge cannot outlive it (OTA phase C, C2.4)

A host captures the grants its session was opened with, and the agent-history
route renders `MobileWebShellScreen` with a pathname derived from
`worktreeId` and no key. So changing worktree updated the screen in place:
the old bridge stayed mounted and kept authorising frames under the grants of
the route the page had already left.

Keyed on the route now, which makes the change a remount — the old bridge is
disposed in the commit, before it can read another frame, and the new session
starts with no grants until its own `init`. The worktree-list and embedded-
browser routes are keyed on the host id for the same reason; the hazard is
the same whenever a dynamic segment moves under a mounted shell.

The probe that catches this uses an empty dependency array on purpose: keyed
on the pathname it re-fires on a prop update and reads exactly like a
remount, which is the one thing it exists to tell apart.

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

* fix(shared): let a manifest declare a native verb as a route grant (OTA phase C, C2.4)

`GRANT_NAME_PATTERN` was dotless, and the contract's own pin asserted a
dotted grant is refused. So no manifest the desktop can produce could declare
`native.clipboard.write` — and once grants are scoped per route, a verb no
route can name is a verb no route is ever granted. Every native verb was
unreachable for every route.

The grammar now admits the verb shape the table names: `native.` followed by
at least two lowercase dotted segments, which is `native.<domain>.<action>`.
A plain name wearing a dot is still refused, `native.navigate` included, so
the pin keeps its meaning.

Wire compatibility, checked rather than assumed: widening what a manifest
field may contain is a new optional value reaching readers that shipped
before it, and the phone's reader already tolerates one. Its route schema
bounds a grant's length and nothing else, deliberately — an unknown name is
not a parse failure that would refuse the whole bundle, it is a grant this
build does not implement, so `implementsGrant` drops it and the route stays
native. Both halves are now tested: an unknown verb leaves its route native
and grants nothing, and a known one reaches `init.grants.native`.

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

* test(mobile): split the session reducer suite by concern before main pushes it over the cap

Merged with main the reducer suite reaches 805 lines against a cap of 800 —
neither side alone crosses it, which is the case the lane rules warn about.

Split at a concern boundary rather than raised: the grant-facing cases (the
cached fallback's own routes, and a manifest verb reaching the session
grants) move to `mobile-web-shell-session-grants.test.ts`, and the fixtures
both suites drive the reducer with move to a shared module beside them, the
way the bridge host suites already share a harness.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 07:07:02 -04:00
latteandNeil bb2afe1792 fix(ai-vault): discover and parse Devin sessions on Windows (#21337)
* fix(ai-vault): discover and parse Devin sessions on Windows, restore workspace mapping

Devin sessions never appeared in the AI Vault on Windows, and parsed
nearly empty elsewhere:

- The transcripts root hardcoded the XDG layout
  (~/.local/share/devin/cli/transcripts), but Devin CLI writes under
  %APPDATA%/devin/cli/transcripts on Windows. The root is now
  platform-aware (APPDATA on win32, XDG_DATA_HOME elsewhere) for both
  local scans and win32 remote hosts, and APPDATA joins the scanner
  child's env allowlist so relocated AppData resolves.
- The parser read metadata.is_user_input / created_at / metrics, which
  real ATIF-v1.7 transcripts don't carry. It now also accepts the ATIF
  step shape (source, timestamp, step-level metrics/model_name,
  plain-string message) while keeping the legacy shape.
- ATIF transcripts carry no working_directory, so sessions couldn't
  group under a workspace. The sibling sessions.db index is now merged
  through the existing sidecar seam: it fills cwd/title/model/
  timestamps, honors the db's hidden flag, and re-merges on db-only
  changes without re-reading transcripts.

* fix(ai-vault): inline Devin transcripts root, harden parser/db edge cases

- Resolve the platform-aware Devin cli dir in agent-sources instead of
  importing the shared devin-cli-data-dir module, which is not part of
  this change (broke typecheck).
- Exclude source:'system' steps unconditionally, even when legacy
  metadata fields would classify them as user/assistant messages.
- Guard unix-seconds conversion against out-of-range values so a single
  bad sessions.db row cannot mark the whole index unreadable.

* fix(ai-vault): watch sessions.db-wal so live Devin metadata cannot go stale

In WAL mode, committed rows sit in sessions.db-wal while sessions.db
keeps its stat until checkpoint, so keying the dependency on the db
alone could serve a stale index. The dependency now observes the wal
when one exists; the reader still opens sessions.db itself.

* fix(ai-vault): probe sessions.db-wal through the WSL-gated stat

existsSync bypasses wslGatedStat and can hang a scan on a stalled 9P
mount; the fs-import guard forbids it in session-scanner modules. The
dependency path resolution is now async and probes through the gate.

* fix(ai-vault): honor zero metrics and array messages in Devin steps

- firstDevinMetricValue skipped explicit numeric zeros, letting a
  lower-priority positive metric win and overstating token totals.
- ATIF allows step.message as an array of content parts; route it
  through extractContentText so those steps still feed title/preview.

* test(ai-vault): cover array-valued ATIF message extraction

The extractDevinStepText fallback that routes an array-valued
step.message through extractContentText shipped without a fixture that
produces that shape, so a future refactor could silently drop the
branch. Pin that an array of text parts feeds the step's title and
preview.

* fix(ai-vault): invalidate old Devin caches and bound database retries

* Discover current Devin ATIF exports alongside legacy transcripts

* Recognize drawn geometry in the browser markup contract test

* Deduplicate Devin exports across transcript roots

* Account for the workspace sleep-state reader in scan budget

* Align OMP integration tests with recorded-path resume

* fix: update scan benchmarks and await relay environment test

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-19 03:32:12 -07:00
Neil dcee2c5df3 fix(deps): update vulnerable transitive packages (#21652) 2026-09-19 03:19:06 -07:00
Jinwoo Hong 62903b7cf6 test(mobile): pin the agent-history page closure golden by golden (OTA phase C, C5.2) (#21649)
* test(mobile): pin the C5 page closure golden by golden (OTA phase C, C5.2)

The suite next door proves the corpus replays byte-identically or in a named
class, as counts over 787 goldens. A count cannot tell this domain's regression
from another domain's improvement, so the 134 recorded at a call site the
agent-history page owns are pinned by id, the way C1's 103 are.

Re-derived at this base rather than taken from the design: 3510 modules, 370
local, 16 under `src/agent-history`, 27 families, 134 goldens. The design measured
3508 / 368 at `889c2b562f`; the two new local modules are `omp-draft-launch.ts`
and `omp-fresh-launch.ts`, which arrived on main, and no family or golden moved.
The `.web.tsx` sibling is the entry: the native file reaches `MobileWebShellScreen`
and pulls the shell in, which adds 46 local modules and the two `mobileWeb.*`
families the C1 docstring excludes by name — so measuring the wrong one of the two
gives the right pin table and a wrong module count.

C1's 22 families are inherited verbatim rather than re-derived. Deriving them from
C2's classification rule disagrees with the committed file on 10 of its 103 pins,
in three ways that are each a true statement read too far: `tasks.smart-source
-search` is a `params-undefined` family and the rule's list of those is the five
C2 adds, not C1's one; `host-worktree-refresh` carries `write-ordinal` and
`result-absent-stream-release` goldens, classes the rule does not model because no
family C2 adds carries one; and "a scenario scripting `{ ok: true }` with no
result" is a property of the scenario a golden derives from, not of its family, so
reading it family-wide moves `worktree-catalog-snapshot`. The rule decides the
five families C5 adds and nothing else.

`c5PageClosureTotals` is pinned beside the per-id walk, and the gate asserts the
run's own class counts over this closure against it. A per-id walk agrees with a
table that is wrong the same way twice: deriving this file with a reader that
skipped `c1-page-closure.ts`'s wrapped entries produced an empty mismatch list, 53
`result-absent-settlement` and 0 `result-absent-stream-release` where the truth is
50 and 3. Only the counts showed it, so only the counts can guard it.

Both halves proved red, not assumed. A verdict changed in the pin fails the gate
as "aivault-history-scan-fulfilled: pinned params-undefined, ran identical"; a
golden dropped from the pin fails three ways — the drift names it, the totals move
72 to 71, and the census count fails.

Tallies untouched: 787 goldens, 396 / 341 / 3 / 6 / 33 / 8, unclassified 0. No
golden moved and nothing under `mobile/rpc-foundation/` was written.

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

* refactor(mobile): give both page closures one drift, not two copies

Round 1 on #21649. C5's pin file arrived with C1's type alias, observation type,
exclusions walk and drift walk copied across, identical but for the names. The
copies are the problem rather than the duplication: these pins are the instrument
two domains argue with when a golden moves between families they share, and C5's
closure contains C1's 22 families entire — so two spellings of "what drifted" can
disagree about the same goldens while both stay green.

`page-closure.ts` now holds the types and four helpers, each taking the pins as an
argument; `pageClosureTotals` moves there too, since it is a general question and
was C5's only real addition. `readPageClosure` joins them: the gate had grown a
local helper to print one domain's line, which is the same shape one step later.
Both pin files are now their table and nothing else, which is the part that is
per series and meant to be read as a diff.

Output asserted rather than assumed. The gate's two closure lines are byte-
identical before and after — "C1 page closure: 103 goldens in 22 families, 54
byte-identical" and "C5 page closure: 134 goldens in 27 families, 72
byte-identical" — diffed as files, and both pin suites report the same 60 cases
with the same verdicts.

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

* test(mobile): read a golden's family through the corpus's own reader

Round 1 on #21649. Both pin tests parsed a golden file and asserted its shape in
order to read one field, each with a `SAFETY` annotation saying every golden
carries `family`. `readGolden` already answers that question properly: it checks
the format version and the value pool and throws a named diagnostic otherwise,
and returns `family` typed.

Both annotations are gone rather than reworded. C1's is fixed in the same commit
because it is the same line — the cast arrived in C5's file by being copied from
it, and leaving the original would have kept the shape that produced the copy.

The assertion is also stronger than it was. Before, a golden whose format version
had moved would still be read and its `family` compared; now that file fails with
the reader's own message instead of being silently accepted.

Checked that it still discriminates rather than assuming: renaming one family in
the pin fails two cases, the corpus-family read among them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 06:13:25 -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
Neil ff934256ae fix(omp): retain recorded transcript paths when resuming (#20634)
Based on the resume-locator proposal in stablyai/orca#16276 by @CodeHourra. Retains UUID-based ownership and existing reattach behavior.
2026-09-19 02:02:50 -07:00
Jinwoo Hong 50f507b731 fix(lint): exempt the browser-pane markup tests from the shape-name rule (#21637)
`0e1a5f39d0` added `ClientHostedBrowserPagePane.markup.test.tsx`, which
drives the markup overlay's API and so names its payload key `shapes`.
The rule already exempts `browser-pane/annotate/**`, where that key is
defined, but the test lives one directory up, so every pull request's
static analysis has failed on main's own file since that commit.

Extend the exemption to the markup tests beside annotate/, the narrowest
pattern that covers the file; no symbol changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 04:57:15 -04:00
Neilandunknown 4dec64d533 feat(source-control-ai): support OMP generation (#20624)
* feat(source-control-ai): support OMP text generation

Read prompts on stdin, retain OMP configured model by default, and reuse JSON model discovery.

Co-authored-by: unknown <1784931579@qq.com>

* test(source-control-ai): cover OMP large input and model overrides

* fix(omp): keep configured model default out of discovered catalog

* fix(omp): hide config default from model discovery catalog

* fix(omp): separate terminal discovery from generation defaults

* test(omp): keep model probe import compatible with CLI typecheck

* test: align Source Control AI registry contracts with OMP

---------

Co-authored-by: unknown <1784931579@qq.com>
2026-09-19 01:54:50 -07:00
Neil ef03188956 fix(sidebar): retain remote OMP rows before tab hydration (#20616)
* fix(sidebar): retain remote OMP rows before tab hydration

* fix(sidebar): sweep host-retracted rows without hydrated tabs

* perf(sidebar): scope mirrored retractions to indexed host tabs

* fix(sidebar): scope status retractions to host identity

* fix(sidebar): retain host identity on mirrored statuses

* chore(ci): refresh validation against fixed main baseline

* fix: scope mirrored tab retractions to owning host panes

* fix(sidebar): preserve foreign pane metadata during host retraction

* fix(sidebar): retain remote workspace attribution after tab hydration
2026-09-19 01:49:50 -07:00
Neil 605a4ef868 fix(omp): start new tasks without auto-resuming old sessions (#20622)
* wip(omp): prove fresh settings overlay without redirecting storage

* fix(omp): guard fresh launches with execution-host settings

* fix(omp): preserve unmodelled shell launch commands

* test(omp): consolidate shell fixture path import

* preserve fresh OMP launch status

* test: cover preserved OMP launch status

* fix: recognize wrapped fresh OMP launches

* chore(ci): refresh validation against fixed main baseline

* fix(omp): recognize generated fresh launch guards across shells

* fix(omp): preserve draft status and clear prefill across Unix shells

* fix(omp): run cmd draft cleanup after either guard branch

* fix(omp): launch drafts safely with nounset enabled

* fix(omp): select draft shell without parser diagnostics

* test(omp): await relay environment augmentation
2026-09-19 01:42:14 -07:00
Neil 14cddfeda7 fix(codex): disable plugins in short-lived probes (#21617) 2026-09-19 01:31:08 -07:00