Commit Graph
8973 Commits
Author SHA1 Message Date
Brennan Benson afb618f2b3 refactor(agent-status): drop two superseded Codex attention workarounds (#21844)
* refactor(agent-status): drop two superseded Codex attention workarounds

Codex fires its PermissionRequest hook as decider #1, before its own
auto-reviewer and before the user, so the event never meant "a human is
blocked". #21389 fixed that at the source: the execution host reads the
turn's approvals_reviewer from the rollout at write time and keeps a
reviewer-owned approval in `working`.

Two older reader-side workarounds for the same bug are now redundant.

The launch-argument suppressor guessed auto-approve mode by string-matching
the launch args, then dropped the status row in the reader. It only matched
Codex's bypass flag, and under that flag Codex's approval policy is `Never`,
which takes the Skip path and fires no PermissionRequest at all. When the
user turns on "Approve for me" inside a live session the args never change,
so it never fired for the actually-reported case either.

The Codex-only 1.5s notification quiet window could not do its job: measured
auto-reviews take 3-20s and a human can answer in under a second, so no
fixed constant separates them. Its deferred callback also re-checked
liveness and returned without notifying, so a genuine prompt whose pane went
non-live inside the window was dropped rather than delayed. Codex now
notifies synchronously like every other agent.

Also types the coordinator's completion state from the controller's exported
CompletionState instead of asserting each field, which the changed-lines
casting gate required once those lines moved.

* fix(agent-status): settle transient process-exit evidence
2026-09-20 23:32:00 -07:00
Neil b2fe56def9 fix(worktree): recognise the Windows profile through WSL's drvfs view (#20051)
* fix(worktree): recognise the Windows profile through WSL's drvfs view

`/mnt/<letter>` under a WSL UNC alias is the distro's drvfs mount of a Windows
volume, so `\\wsl.localhost\Ubuntu\mnt\c\Users\bob` is `C:\Users\bob` wearing a
Linux spelling. The Windows-profile rule excludes every WSL UNC path by design
(the aliases normally front a Linux filesystem) and the POSIX shapes never match
a `/mnt/...` tail, so that path fell through both and read back as deletable.

The spelling is producible by the product: `resolveWslRepoWorktreeBasePath` maps a
`/mnt/c/...` worktree base against a WSL repo into exactly this UNC form, and
`getWslFilesystemBoundaryDistro` already treats it as the drvfs crossing.

A drvfs tail now takes the Windows rule on its drive form, via the existing
`toWindowsWslDrivePath`. Scoped to the UNC branch, where `parseWslUncPath` has
proven the path is a WSL alias — a plain Linux host's `/mnt/c/...` is untouched.
The lowercase-only `/mnt` match is deliberate: `/MNT` is an ordinary
case-sensitive Linux directory, never the automount.

* fix(worktree): refuse the drvfs volume root and the automount under a WSL UNC alias

`\\wsl.localhost\Ubuntu\mnt\c` is the whole C: volume and `\\wsl.localhost\Ubuntu\mnt`
holds every drvfs volume. Neither is caught by the root check in
`isDangerousWorktreeRemovalPath` (their win32 root is the distro share) nor by the
Windows-profile rule on the drive form (`C:\` is not `C:\Users`), so both read as
deletable. Measured on a Windows 11 host with WSL2: `rm -rf` inside the distro on the
`/mnt/c` spelling deletes on the Windows drive.
2026-09-20 23:26:51 -07:00
Brennan Benson 4da3a95d50 fix(native-chat): scope a tool row's hover reveal to that row (#21918)
Hovering one tool call in a chat turn revealed the expand chevron on every
other row in the same message at once, so the whole message lit up and nothing
said which row the click would open.

The rows were reading a hover they do not own. Tailwind's unnamed
`group-hover:` is not nearest-ancestor scoped — it compiles to
`:is(:where(.group):hover *)`, which matches a hover on ANY `.group` ancestor.
`NativeChatMessageRow` wraps the whole assistant message in a bare `group` for
its own copy/timestamp reveal, so every collapsible row nested inside it
answered to that wrapper as well as to itself.

Each row now names its own group — `group/tool-line`, `group/tool-run`,
`group/subagent-run`, `group/diff-card` — which compiles to
`:is(:where(.group\/tool-line):hover *)` and reaches that row alone. The
message-row reveal is left bare on purpose: its copy button and timestamp are
meant to answer to a hover anywhere in the message.

`NativeChatDiffCard` is included for the same defect, not as extra scope: its
verb label was brightening on any hover in the message.
2026-09-20 23:26:29 -07:00
Neil 8cf1c8594e docs(opencode2): clarify quick command delivery (#21929) 2026-09-20 23:22:27 -07:00
Neil 70c4f20466 fix(opencode2): auto-submit quick command prompts
OpenCode2 quick commands now submit through the ready-state delivery path. Focused regression coverage and full CI pass.
2026-09-20 23:08:36 -07:00
Neil 98299d879b fix(terminal): persist a parked remote pane's scrollback across a hard restart (#21295) (#21367)
* fix(terminal): route a parked pane's scrollback patch to the remote host's partition

A park capture changes only terminalLayoutsByTabId, so its debounced session
patch carries no tabsByWorktree. splitWorkspaceSessionByHost built its
tab->worktree index from the patch alone, resolved nothing, and routed every
layout to the 'local' partition, where main's pruneLocalTerminalScrollbackBuffers
strips scrollback it cannot attribute to a remote worktree. The remote host's
runtime:<id> partition never received the capture, so anything parked since the
last clean checkpoint was lost on a crash, SIGKILL, or a forced kill during an
app update (#21295).

Route tab-keyed patch fields with the renderer's live tab catalogs as a fallback
when the payload names no tab rows. Payload rows still win, so full-payload
writes are byte-identical. Once routed to runtime:<id>, main merges the
partition's own prior tabsByWorktree and the prune preserves.

Proven by tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts: a
hard kill (no checkpoint) then relaunch, asserting the capture is in the remote
host's partition on disk. Mutation: reverting the routing fix turns that
assertion red and fails the 3 catalog-dependent unit routing tests.

(cherry picked from commit 58a344c1d4)

* test(terminal): read both scrollback homes in the restart spec, and ratchet the resolver to the cap's home list

The restart spec read only buffersByLeafId, but the ordinary park now writes
localOnlyScrollbackByTabId, so its own proof reported a false zero and both tests failed for the
wrong reason. Both readers now go through resolveLeafScrollbackBuffers: the on-disk reader calls
it directly (it is a pure function), and the store reader — which runs inside page.evaluate —
reaches it through a new window.__terminalParkingDebug.resolveLeafScrollback(tabId) handle.

resolveTabScrollbackBuffers is typed off TERMINAL_SCROLLBACK_SESSION_HOMES and its unit test
enumerates that constant, so adding a third home fails to compile and fails a test until the
resolver reads it — the 'no consumer reads a home directly' invariant becomes enforceable.

The clean-quit control no longer asserts tokenAfterReveal (measured true, true, false on identical
product code; the live host can serve the reveal from its own tail). It keeps the five
deterministic fields and logs the reveal; the hard-kill test still asserts it, because there the
host is forced unavailable and the reveal must come from the client copy.

(cherry picked from commit 0cd3db1489)

* docs(persistence): pin why the local-only scrollback home stays outside full normalization

The two scrollback homes look symmetric (TERMINAL_SCROLLBACK_SESSION_HOMES), so the missing key
reads as an oversight. It is load-bearing: adding it would route the field through the fail-closed
strip and reintroduce the loss this branch fixes. The renderer prunes it with attribution before
the patch is sent, so the cap still holds without main as a second line.

(cherry picked from commit 1092e35b0a)
2026-09-20 22:57:33 -07:00
Neil f492054bf0 fix(runtime): an outage is not a handle-gap verdict (#20059)
* fix(runtime): an outage is not a handle-gap verdict

The per-pane handle-gap wait releases at a 15s deadline and records that
expiry as a verdict, which authorises the sleeping-agent resume. The
connection generation was the only thing voiding that verdict, and a plain
disconnect never advances it — runtime-status.ts advances on the reconnect,
under a new runtime id. So a network drop mid-turn expired the wait with a
generation that still matched, and the replay forked a second `--resume`
onto the transcript the host was still writing: #19735 through the
disconnect door.

Suppress the verdict while the client positively knows it is out of contact,
reusing the shared runtime-host connection derivation. The waiter still
releases and re-parks, so contact returning gets a full fresh budget and the
pane is still decided on real silence.

Not redundant with the landed-handle drain that follows this commit, nor with
the read-time pane identity from adv2-skew (cdafc90d8f). Mutation on the
composed tree gives three disjoint kills: dropping this guard fails only "does
not turn an outage into a verdict"; dropping the landed-handle drain fails only
the two landed-handle cases; forcing this guard always-true fails 16 across every
suite. Three guards, three holes.

* fix(runtime): an outage inside the budget is not a handle-gap verdict either

The contact check at the deadline is a snapshot of now. An outage that began
and ended inside one wait, on the same runtime, leaves contact restored and the
generation untouched, so the deadline recorded a verdict after milliseconds of
real contact. Capture hostContactEpoch at park time and refuse the verdict when
it moved; the waiter still releases and re-parks for a fresh budget.

Also pins the transport-down snapshot shape (reads 'reconnecting'), which is
what main's status channel actually publishes for a dropped link and was the
one arm of the guard no test exercised.
2026-09-20 22:56:43 -07:00
Neil 4feca6baa1 Keep new worktree dialog actions visible while scrolling (#21915)
* Keep new worktree dialog actions outside scrolling content

* Trigger missing PR checks
2026-09-20 22:54:03 -07:00
SahilZ0810andNeil 9324bb8137 fix(editor): preserve Markdown preview when following wiki links (#19790)
* fix(editor): preserve preview when following wiki document links

* test(editor): avoid cast in markdown navigation fixture

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-20 22:52:45 -07:00
Neil 2872c3fccc fix(claude): prefill continuation context for manual submission (#21912)
* fix(claude): prefill continuation context for manual submission

* fix(agents): force draft paste when inline prefill falls back
2026-09-20 22:45:05 -07:00
Jinwoo Hong 5d13a70ea3 fix(mobile): keep an in-page hop local only when the session's grants cover it (OTA phase C, C2.9) (#21723)
* feat(mobile): carry what each page route declared in init (OTA phase C, C2.9)

The page decides an in-page hop from `init.pageRoutes`, which says which patterns
this shell would render and nothing about what each one costs. So a push kept
local on the strength of the pattern alone runs the target under the opener's
grants — which is how the tasks page is reached from the wide-layout sidebar
without `native.clipboard.write`, and why its copy actions refuse silently.

`init` now also carries `pageRouteGrants`, the manifest's own route/grant pairs,
from the manifest the shell already holds. Optional in both directions: an older
shell omits it and an older page ignores it, and a page that receives none keeps
today's rule. No new frame kind, no cap change, no protocol bump.

The grammar is the manifest's, imported rather than restated
(`MobileWebBundleGrantNameSchema`, now exported for this), so a grant name the
bundle could not have declared cannot reach the page through this field either.
The host validates the pairs before it builds the frame and refuses the session
when they fail, for the reason it already refuses a malformed route: an `init`
the page would reject whole is worse than no session at all.

Two files were at their line ceiling and are split rather than bumped. The pairs
schema moves to `bridge-page-route-grants.ts`, which is read by both the envelope
and the host, so it belonged in one place anyway. In the session reducer the
three sites that each spelled out "patterns, their grants, this route's grants"
become one `routeViewOf`; that is a net reduction and removes the fourth spelling
before it is written.

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

* fix(mobile): keep a hop local only when the session's grants cover it

The rule the page was using is "the shell would render this pattern", and that is
not the question. Grants are resolved once, from the route the shell opened, so a
push kept local runs the target under the opener's list. On a wide layout the
sidebar renders beside every `/h` route and pushes `/h/<id>/tasks` through this
seam, so from the worktree list, agent history or the files pages the tasks page
ran without `native.clipboard.write` and its copy actions refused with nothing on
screen to say why.

`servedHere` now means served here *and* covered: the target's declared grants
must be a subset of this session's. An uncovered page route is handed to the
shell exactly like a non-page route, and the shell opens it as its own session
with its own grants — which is the mechanism that already exists, rather than a
new one.

Three answers, not two, because an absent field is not an empty one. A shell that
sent no pairs keeps the old behaviour: `null` is "nobody told me", and an older
shell has to keep working. A target the shell lists but names no entry for is
*not* covered — the page cannot justify that hop, so it hands it over rather than
guessing in the direction that loses grants.

This is C3.1's explorer ⊇ preview finding without its pairwise pin: that hop is
covered by this rule and stays local, and the rule scales to the sidebar, which
reaches every route and which no pairwise list can keep up with.

Red first on the two cases only the new rule answers; the other four are the
regression guards and passed before and after. Two whole-session assertions
gained `pageRouteGrants: null`, which is what the reader now returns.

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

* test(mobile): prove the sidebar hop in a browser, under the session's own grants

The unit tests pin the decision; only a browser shows the control exists, is
reachable at the viewport where the sidebar renders, and that the document does
not move when the hop is handed over.

Four cases on the shared harness, which now forwards `pageRouteGrants` (omitted
when a caller names none, because an absent field is not an empty one and the
page reads the difference).

- Wide, session without `native.clipboard.write`: tapping Tasks posts exactly one
  `navigate` notify, the document stays on the worktree list, and **no new chunk
  is fetched** — which is what says the page did not quietly render tasks under
  the wrong grants.
- Wide, same tap with the grant added: no notify, the document moves to `/tasks`.
  Without this the first case would pass on a page that simply never navigates.
- Wide, shell sending no pairs at all: the old behaviour, local. An older shell
  must not start handing every hop over on a field nobody sent.
- Narrow: asserts the absence rather than a tap. `app/h/_layout.tsx` renders the
  sidebar only on a wide layout, and only that header branch labels its Accounts
  and Tasks controls; the narrow header's are unlabelled pressables. So the hop
  does not exist at that viewport, and `getByLabel('Tasks')` finding nothing is
  the honest assertion. That unlabelled narrow header is a real accessibility gap
  and is not this lane's to fix.

Registered in `pr.yml`'s `mobile_web_app` job beside the other render checks.

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

* test(mobile): census the in-page hops a session's grants cannot cover

The rule landed in the commit before this one decides each hop; this says which
hops those are, so a route's grants growing — or a new push between two page
routes — shows up here rather than as a verb that silently refuses on a device.

Openers are every page route, not the one that happens to push. On a wide layout
`app/h/_layout.tsx` renders the worktree-list sidebar beside every `/h` route and
its header pushes tasks, which is exactly why a pairwise pin is the wrong shape:
the sidebar reaches everything, so the census has to be the cross product of what
the manifest declares against what the source actually builds.

Targets come from the hrefs the app builds, read out of `mobile/src` and
`mobile/app` and reduced to route patterns, so a hop nobody writes is not pinned
and a hop someone adds is. A presence case asserts the sidebar's tasks push is
among them, because a census that stopped finding hops would go quietly green.

Two hops are pinned as handed off today, both into tasks, which is the only route
declaring more than `navigate` and `storage`. A third case asserts the other half
of the rule on the manifest: a target asking for no more than its opener stays in
the document.

Checked that it discriminates rather than assuming: widening the worktree list's
grants to cover tasks fails the pin, and restoring them passes it.

**No pin was deleted.** The brief expected C3.1's pairwise explorer/preview pin to
be replaced here, but C3.1 is not on this base — `MOBILE_WEB_PAGE_ROUTES` has
three routes and no `files` entry, so there is nothing to remove. When C3.1 lands,
its pin is this census's to subsume.

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

* fix(mobile): drop an unused import from the hop census

`statSync` was imported and never used; `oxlint` fails it. My error: I committed
the census on a green test run without waiting for lint, the same order mistake I
made earlier in this lane. Fixed forward rather than amended, because the lane
forbids rewriting a commit that exists.

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

* test(mobile): fold C3.1's pairwise grant pin into the hop census

C3.1 landed while this branch was open, and it brought the case this lane
generalises: the explorer pushes to its own preview, that push stays in the
document, so the preview runs under the explorer's grants. Its pin asserted that
one pair by name.

The census now covers it as a consequence rather than a rule. With the files
routes in the manifest the cross product finds six more hops the session cannot
cover — the sidebar into files from the worktree list and from agent history, and
both files routes into tasks — and it does **not** find explorer → preview,
because the preview declares no more than the explorer. That absence is the
pairwise pin, derived.

So the pairwise block is deleted, with its import. The rest of that file stays:
its external-link seam checks and its clipboard-absence control are about what
the files closure contains, which this census says nothing about.

Checked the extended census still discriminates: granting the explorer
`native.clipboard.write` fails the pin, restoring it passes.

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

* test(mobile): prove the sidebar hop from a files route, not only the worktree list

The defect is not "the worktree list pushes tasks". On a wide layout the sidebar
renders beside every `/h` route, so the same hop exists from the files explorer,
whose session carries `externalLink` but not `native.clipboard.write`. One opener
proving the rule would have left the general case to inference, which is the
inference C3.1's pairwise pin already made once.

Opened on `/h/<id>/files/<wt>` with the files route's own grants, the sidebar's
Tasks control posts exactly one `navigate` notify, the document stays on the
files route, and no new chunk is fetched.

The harness helper now takes the route and the text to wait for, so a case can
open on something other than the worktree list without a second copy of it.

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

* fix(mobile): make the render helper wait on the text its caller named

The `awaitText` parameter I added in the commit before this one was never wired
into the wait, so it was dead and `oxlint` failed it. The case still passed,
because the files route renders the host name in its sidebar and that is what the
helper was still waiting on — which is exactly the kind of accident a dead
parameter hides.

Third time in this lane I have committed on a green test run before lint
finished. Fixed forward, not amended.

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

* fix(mobile): carry route grants through the download path

`onManifestRead`'s download branch set `pageRoutes` and `routeGrants` from the
new manifest and dropped `pageRouteGrants`; nothing downstream recomputes it, so
every first install and every OTA update reached `ready` with the default or the
previous generation's pairs. The page then read each target as listed-with-no-
entry and handed off every in-page hop.

`routeViewOf` moves to `page-route-policy.ts`, beside the two functions it calls,
to keep the reducer under its line cap without a bump; its stale neighbouring
comment, which described a filter that moved into it, goes.

Red first: the cold-cache and generation-change cases failed, the cached-hit case
already passed.

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

* test(mobile): derive census targets from navigation call sites

The reachability filter was inert. Harvesting every `/h/${…}` template caught the
five screens that declare their own mount pathname, two `pathname ===`
comparisons and the route template types, so every declared route was reachable
through its own mount: the pinned table was the all-pairs one, eight hops with
the filter and eight without.

Targets now come from the arguments of `router`/`navigation` `push`, `replace`
and `navigate`, and of `navigateFromHostList`; mounts, comparisons and types are
excluded by construction because they are not navigation arguments. Two real
hops are not written as a literal, so a local binding or a call is followed one
step to the function that returns the pathname: the files explorer is pushed as
`{ pathname: descriptor.pathname }` and the preview as
`push(createMobileFilePreviewHref(...))`. A call site whose target cannot be read
is returned rather than dropped.

Derived patterns go from 11 to 10; the pinned table stays at eight because all
five page routes are genuinely pushed to. What changes is that the filter now
discriminates: deleting the header's two tasks pushes reds the presence case and
drops the four `-> tasks` rows from the pin, where the old derivation stayed
green on the same deletion because `app/h/[hostId]/tasks.tsx` still declared the
pathname. A push added at a real call site appears in the set.

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

* test(mobile): restore the preview-declares-something guard

The pairwise pin this case replaced asserted the preview declares at least one
grant before asserting the explorer covers them all; without it two empty lists
satisfy the subset check and a route that lost its grants passes.

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

* docs(mobile): describe the route list under the handoff rule

Two passages described the world before this PR: the explorer's note said the
census pins its pair with the preview, and a closing paragraph left the sidebar's
tasks hop open for a later PR. This is that PR. Covering the preview now buys the
in-document hop rather than making it correct, an uncovered target is handed to
the shell and reopened under its own grants, and the census reads the explorer to
preview relation off this list rather than pinning it by name.

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

* test(mobile): mirror the manifest's tasks grants in both fixtures

CodeRabbit on #21723: both fixtures declared the tasks route as `navigate`,
`storage`, `native.clipboard.write` while the manifest also declares
`externalLink`, so no covered-session case ever required it.

Both now mirror the manifest's four, and the covered sessions hold them. That
alone does not make an `externalLink`-blind rule fail, since those sessions hold
every grant either way, so the unit suite gains the case that does: a session
holding the clipboard but not `externalLink` must still hand the hop off.
Mutating the rule to treat `externalLink` as always held reds that one case and
no other.

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

* test(mobile): make a stalled hop name its own cause

Both waits for the hop to land read as a bare 30 s timeout when it does not. The
CI failure that sent this file back was a `TypeError` inside React Navigation
that blanked the document, and it was invisible here because the error
assertions run after a wait that never returns.

The wait now throws with the page's own account: the pathname it stayed on, the
collected page and console errors, the `navigate` notifies posted, the first 300
characters of the body, and every `.js` response since the click with its status.
The response listener records every script answer rather than only the 200s, so a
chunk the navigation waits on can be seen failing; the 200-only list the
no-new-chunk assertions read is unchanged, as is everything the five cases
assert. Kept in this file because no other render file waits on the pathname
moving.

Proved by mutating the rule to hand every hop off: the covered case fails naming
the pathname it stayed on, an empty error list, the notify it posted and no
scripts since the click — which is the handoff signature, distinct from the
crash signature CI saw.

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

* test(mobile): aim the narrow hop at the control C2.10 named

The narrow case asserted the absence of a labelled Tasks control, which
was true only because the narrow toolbar carried no accessibility props.
C2.10 gave it the wide sibling's role and label, so the assertion was
red on the merge and, worse, the rule this file is about went unproven
on the branch the phone actually presses.

It taps that control now: at 390 px there is exactly one, and the tap
posts exactly one navigate notify for the tasks route while the document
stays on the worktree list and fetches no new chunk. Red first against
the merged header (count 1, expected 0); with the session given
native.clipboard.write the hop goes local and the case reds, which is
what says the assertions discriminate.

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

* docs(mobile): drop the handoff predicate's contradicted one-liner

The pre-C2.9 summary said the answer is whether this document renders
the target, which is exactly the claim the block comment below it
replaced: the predicate now also requires the target's grants to be
covered. Two doc comments on one declaration, the first of them wrong.

Comment only; the 35 handoff cases are unchanged and green.

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

* test(mobile): assert which field a route refusal blames

The host builds `pageRouteGrants: <issue>` so a refusal says which of the
two checked inputs failed, and nothing read it: the case counted
refusals, so a host that reported the route's own verdict for a malformed
pair would have stayed green while sending whoever reads the refusal to a
pathname that was never the problem.

The case pins the prefix, a non-empty issue behind it, and that the
diagnostic and the callback carry the same string. The control is an
opener that fails the other way: a malformed route reports its own issue
and does not take this prefix, without which the pin would hold on any
reason at all.

Red first with the field branch dropped from the reason: the prefix
assertion fails and the control stays green.

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

* refactor(mobile): stop exporting the route filter the reducer stopped calling

`implementedPageRouteEntries` and `implementedPageRoutes` were the
reducer's two ways in before it moved to `routeViewOf`. The entries form
had no caller anywhere afterwards and the patterns form had only this
test, so the module's public surface advertised two functions no product
code reaches. Both are module-local now; the surface is
`matchesRoutePattern`, `pageRendersRoute`, `grantsForRoute`,
`routeViewOf` and the grant list.

The test reads the same list through `routeViewOf(...).pageRoutes`, which
is the reducer's own view of it, so no assertion changed and no export is
kept for a test.

Red first: with both un-exported and the test untouched, seven cases fail
with `implementedPageRoutes is not a function`; routed through the view
all nineteen pass. Still discriminating, as a control: with the grant
filter dropped from the entries helper, four of them fail.

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

* test(mobile): route the merged haptics cases through the policy view

PR E's two haptics cases arrived with the merge calling
`implementedPageRoutes`, which this branch had already made module-local,
so the merged file was red with `implementedPageRoutes is not defined`
on both of them. They read the same list through `pageRoutesOf`, the view
the rest of the file already uses, so neither assertion changes.

PR E's paragraph named that function for the filter it describes; the
filter now sits in the entries helper the view is built on, so the
sentence says that instead of naming a function the reader cannot see.

Red: the two cases above on the merge. Green: all 21, PR E's two included.

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

* test(mobile): mirror the haptics token in every handoff fixture

PR E put `haptics` on all five manifest routes, and these fixtures still
carried the pre-E grant lists: tasks with four grants where the manifest
now declares five. A fixture that is short the same token on both sides
of the subset check agrees with the rule by accident, and would have gone
on agreeing after the token stopped being universal.

The pairs mirror the manifest now, and each session carries what its
opener route would actually be granted, since the host narrows a route's
declared grants to what the shell implements and the shell implements the
token.

Red first, with the token added to the pairs alone: the two covered-hop
cases flip to handed-off, `stays in this document when the session
already covers the target` and `keeps the hop in the document when the
session covers tasks`. Green once the sessions carry it, 35 and 5.

The hop census needed nothing: it reads `MOBILE_WEB_PAGE_ROUTES` itself.
Measured there, all 5 routes declare the token and it is the missing
grant in 0 of the 8 uncovered pairs, so it cannot decide a hop and the
rule still reads only `pageRouteGrants`.

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

* test(config): count C2.9's two bridge modules in the session route closure

#21908 recorded this pin at 4,324 for the haptics notify module. C2.9
adds two more that the same closure reaches: the page-route-grants schema
and the manifest contract whose grant grammar it imports rather than
restates, both pulled in by `bridge-envelope.ts`, which the page reads to
parse `init`.

Named in the docstring beside #21908's sentence rather than folded into
its number, because the three modules arrived from two PRs and a single
count with one reason invites the next author to assume the rest.

Red first against 4,324: expected 4,326. Measured on this head, not
inferred -- a control worktree at pristine main gives 4,324, so the two
are this branch's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 01:29:02 -04:00
Neil c74ba15f31 fix(claude): stream the provider history window (#21742)
* fix(claude): stream the provider history window

An oversized Claude transcript made restart reconciliation unresolvable:
readClaudeProviderHistoryWindow buffered the whole project JSONL, so a file
past the 16 MiB bound returned an inconsistent boundary — the one answer the
reconciler can never act on — and a file just under it still went resident.

The window now reuses the streaming primitives from #21024 instead of a
whole-file read. Two bounded passes run over ONE pinned descriptor and size:
the graph pass builds the branch proof (uuid/parentUuid only), and the replay
pass hands back the first record per chain uuid, fingerprinted on the spot. A
repair appended mid-read re-runs BOTH passes at the grown size, so the replay
can never read bytes the proof did not vouch for. The 16 MiB bound survives as
a per-record framing limit, which is the only remaining way the source could
become resident.

claude-transcript-branch-proof.ts is split at its real seam to stay under
max-lines: claude-transcript-branch-graph.ts is what the rows mean as a graph,
and the proof file is which bytes the graph gets to see.

Peak heap over a 252 MiB transcript: 542 MiB whole-file, 53 MiB streaming —
and flat at 53 MiB for a 63 MiB transcript, where whole-file took 136 MiB.

* fix(claude): fail closed when the ancestry walk misses the anchor

The source-budget anchor test filtered on `"latest"`, which also removed the
last-prompt marker. The transcript was unprovable, so the empty window and
single pass it asserted came from an INCONSISTENT verdict, not from the
leaf-equals-anchor path. Filter the record only and assert the boundary.

`ancestryChain` returned [] both for "the leaf IS the anchor" and for a walk
that fell off the graph. The first means nothing followed the anchor; the
second means we never looked. Throw on the second, so the window reports an
inconsistent boundary rather than non-delivery.
2026-09-20 22:12:23 -07:00
Neil 253f0e3946 Fix Antigravity source-control model discovery and retired defaults (#21606)
* fix(antigravity): discover current source-control models and use CLI defaults

* fix(antigravity): gate configured models on remote runtime support

* fix(runtime): forward default TUI agent for remote git generation

* test(runtime): cover inherited agent forwarding
2026-09-20 22:12:02 -07:00
Jinjing 00da5fd556 test(worktrees): add comprehensive nested lineage coverage (#21903)
- Add 10 test cases for nested worktree rendering and collapse behavior
- Handle edge cases: cycles, uneven siblings, multiple depth levels
- Extract stopNestedWorktreeCardBubble to shared header-event-guards module
2026-09-20 22:11:08 -07:00
SahilZ0810 ffb79c71e0 fix(editor): open plain details blocks in rich markdown mode (#19784)
* fix(editor): allow plain details blocks in rich markdown mode

* fix(editor): preserve case-sensitive details class values
2026-09-20 22:05:30 -07:00
Neil 27a0889dcf test(relay): account for OpenCode marker in OMP launch environment (#21907) 2026-09-20 21:41:58 -07:00
Neil 35005fb65c fix(pi): keep panes working while async subagents run (#21882)
* fix(pi): wait for async subagents before settling pane

* fix(pi): handle subagent event aliases and reloads

* test(pi): assert lifecycle listener cardinality
2026-09-20 21:38:38 -07:00
Jinwoo Hong ba7583244b fix(editor): persist PDF zoom across tabs and restarts (#21879)
* fix(editor): persist PDF zoom preferences

* fix(pdf): avoid path-only zoom persistence
2026-09-21 00:24:20 -04:00
Neil 7b97551acf fix(opencode): isolate v1/v2 plugins and preserve WSL config (#21900)
* fix(opencode): include cache read and write usage totals

* fix(opencode): satisfy aggregate query safety checks

* chore(i18n): refresh runtime English catalog

* fix(opencode): isolate plugin variants and preserve WSL config
2026-09-20 21:09:23 -07:00
Jinwoo Hong 8a3a119052 fix(i18n): regenerate the runtime catalog and localize the cookie example from #21462 (#21889)
* fix(i18n): regenerate the runtime-required English catalog for the AccountsPane strings

#21462 (7a6d10064e) added eight en.json entries without re-running the
generator, so `verify:localization-runtime-catalog` fails on main and on
every PR's merge ref. Generated with `--fix`; no hand edits.

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

* fix(i18n): localize the cookie-header example in the OpenCode Go setting

The same commit (#21462) left one `<code>` example as raw JSX text, so
`verify:localization-coverage` fails on main once the runtime catalog
passes. The text already has a key (its placeholder twin uses
`auto.components.settings.AccountsPane.37b4b4a3f7`); reuse it. Control:
with this edit reverted the check names exactly this site, with it
restored the check passes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 00:07:50 -04:00
Neil 87a22db25a fix(opencode): include cache read and write usage totals (#21886)
* fix(opencode): include cache read and write usage totals

* fix(opencode): satisfy aggregate query safety checks

* chore(i18n): refresh runtime English catalog
2026-09-20 21:02:24 -07:00
Jinwoo Hong 58a80d996b test(runtime): expect the agent's own submit delay in the PTY timing policy case (#21874)
#21665 gave antigravity a per-line settle before Enter, so the delay the
test computed from bytes alone is 45 ms short of what the runtime waits.
Under fake timers that leaves the submit pending until the real 30 s
timeout, which is what every PR's node shard 8/8 has been failing on
since it landed. The case now derives the expected delay from the agent's
policy, so a future per-agent term moves the expectation with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 23:42:00 -04:00
Wooseong Kim 7a6d10064e fix: read OpenCode Go usage from the console API (#21462)
* fix: read OpenCode Go usage from the console API

The workspace HTML page now 302s to console login. Fetch
/console/api/go/status with x-org-id, map JSON meters into the
existing windows, and keep __Host-console_session on the closed
cookie allowlist.

Fixes #21420

* fix: tell users to paste the OpenCode console session cookie

The Go status API is authed by __Host-console_session. Settings still
told people to paste auth only, which 401s. Ask for the full Cookie
header; auth remains enough for workspace discovery.
2026-09-20 20:12:08 -07:00
Pablo Werlangandorca-agent 646fa3645f fix(opencode): attribute shared-server sessions to their panes (#21577)
* docs: allow-list opencode tool-readout follow-up note

* fix(opencode): attribute shared-server sessions to their panes

The v2 shared server stamps every hook post with its own frozen pane,
so all panes' status lands on the starter pane (#21359).

- shared: session->pane registry plus ingest-time envelope rewrite;
  bound sessions resolve to their real pane, tab and live launch token
  before disposition, unbound sessions keep the stamped identity.
- main: binder poll (SQLite session store, PTY-registry pane snapshots,
  argv-aware client sweep) with directory-containment plus
  client-lifetime correlation; 60s loop plus debounced SessionStart kick,
  wired into the hook server lifecycle.

* fix(opencode): newest-wins pane dedupe, macOS private/tmp normalization

Live verification against the dev instance found two binder gaps: remint
rows for one pane counted as an ambiguous tie, and /tmp vs /private/tmp
spellings never met on macOS.

* fix(opencode): review fixes — newest-wins worktree, drop dead constant

- applyBinderOwnerships now overwrites per-pane worktree, matching the
  round's newest-wins pane dedupe; a remint's live row wins over a stale
  row (pinned by test).
- remove the unused OPENCODE_CLIENT_PRE_CREATE_WINDOW_MS export and the
  nowMs residue from clientCouldCreate.
- give the per-pane launch-token cache its own named cap constant.

* fix(opencode): address thread review — cursor, native table, tokens, lifecycle

- composite (time_created, id) store cursor advanced past handled rows
  only, so same-millisecond pagination and full unbound maps no longer
  drop sessions silently.
- Windows sweep reads the native process table instead of forking
  powershell.exe; quote-aware argv parsing on both platforms.
- directory keys via normalizeRuntimePathForComparison (Windows
  case-fold, POSIX backslash literals) plus narrow macOS /tmp|/var|/etc
  aliases and lexical dot-segment resolution.
- bound sessions always take the stored pane token (never the frozen
  stamp); token tracking runs after resolution.
- binder generation guard discards post-stop rounds; first round runs
  immediately at loop start.
- unbind/move use exact pane-key match; pane launch-token cache gets its
  own cap constant.
- move the tool-readout note out of this PR for its own branch.

* fix(opencode): second review round — executable field, worktree scope, round lifecycle

- POSIX sweep reads comm= alongside args= and classifies on the
  kernel executable name, so unquoted install paths with spaces no
  longer split argv[0] and reject the client; Windows rows carry the
  native table name. Degrades to argv[0] when comm is unavailable.
- bound sessions take only the binding's worktree (never the stamped
  pane's), so a worktree-less binding cannot file a row under the
  wrong worktree.
- the binder generation is captured before the round body and the
  running flag clears only for the current generation, so an obsolete
  post-stop round cannot admit an overlapping round.

---------

Co-authored-by: orca-agent <orca-agent@local>
2026-09-20 20:06:55 -07:00
Wooseong KimandNeil 30f2bc60f9 fix(antigravity): recognize non-Gemini tui-idle prompts (#21231)
* fix(antigravity): recognize non-Gemini tui-idle prompts

* fix(antigravity): reject stale composer caret in model picker

* fix(antigravity): do not treat a wrap continuation caret as ready

An unsent composer can show `> draft` then an indented `>`. That continuation is not an empty input box, so tui-idle must stay false.

* test(antigravity): align later bare-caret status expectation

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-20 18:01:45 -07:00
Seongho BaeandCursor ea5152f1c2 fix(orchestration): line-settle delay for antigravity multiline paste (#21665)
* fix(orchestration): retry Enter after cursor-agent worker-start paste

Worker-start dispatches through bracketed paste in the main process; cursor-agent
can leave long prompts as "Pasted text +N lines" and swallow the first Enter.
Apply the same submitRetryDelayMs path Codex uses in the renderer, but only for
agents without the Claude/Codex render gate so hook turn-start reservation stays intact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(orchestration): line-settle delay for antigravity multiline paste

Antigravity 1.2.x expands long bracketed paste slowly ("↑ N more lines") while
Orca only waited for byte ingest (~500 ms on macOS). Add submitLineSettleMsPerLine
and retry Enter for antigravity; wire agent-aware submit scheduling through the
main-process prompt writer and plain terminal.send suffix path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(orchestration): antigravity line-settle only; drop unverified retry

Address PR review: revert accidental pnpm-lock.yaml churn, remove cursor and
antigravity submitRetryDelayMs until live-verified, keep submitLineSettleMsPerLine
for agy multiline paste, and move the regression test out of the 900+ line runtime
submission suite.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 18:01:41 -07:00
Neil 438744ca77 fix(opencode): preserve global config discovery (#21854) 2026-09-20 17:58:37 -07:00
buf0-bot[bot]andbench 41f34f6ff2 fix(terminal): let Shift+middle-click paste in mouse-tracking panes (#21858)
* fix(terminal): let Shift+middle-click paste in mouse-tracking panes

Follow-up to #21834 (issue #21762). That fix arms the native-paste
suppression window for every terminal middle-click, then returns early
when the pane is in mouse-tracking mode so the TUI performs the paste
from the forwarded mouse report.

xterm's SelectionService.shouldForceSelection deliberately withholds
that report for a shifted click (Option-click on Mac), so the TUI never
pastes. With the native follow-up paste now suppressed as well,
Shift+middle-click in Claude Code, Codex, and other tracking TUIs pasted
nothing at all. Previously Chromium's native paste was the one paste.

Mirror xterm's platform rule: when the click's modifier forces
selection, fall through to Orca's own paste-to-PTY path (stop
propagation, focus, paste) exactly as in a non-tracking pane. The
auxclick handler gates stopPropagation the same way.

* test(terminal): pin Alt+middle-click to the TUI-owned path off Mac

---------

Co-authored-by: bench <bench@example.invalid>
2026-09-20 17:33:34 -07:00
Neil e0b717dd60 test(antigravity): cover Kitty mode reattach metadata (#21810) 2026-09-20 17:18:56 -07:00
Neil 76d87604ff fix(terminal): keep drag selection stable during redraws
Pause output-driven drift fitting while the user drags a terminal selection, then converge immediately on mouseup. Includes regression coverage and visual proof.
2026-09-20 17:08:52 -07:00
Neil 3b055c869f fix(wsl): await Pi and OMP guest relay materialization (#21721)
* fix(wsl): await Pi and OMP guest relay materialization

* fix(wsl): materialize Pi extension before guest launch

* fix(wsl): keep relay state under lint limit

* fix(wsl): preserve guest agent readiness across launches

* test(wsl): expect guest status path translation

* ci: rerun PR checks after rebase

* ci: retrigger PR checks

* ci: run final PR verification
2026-09-20 16:17:54 -07:00
OrcaWinandm4air 4085e1cf60 fix(memory): release stale session registries (#21734)
* fix(memory): bound session and lifecycle registries

* fix(memory): bound transient filesystem registries

* fix(memory): cap path and locale caches

* fix(memory): bound runtime recovery registries

* fix(memory): bound host mirror gap verdicts

* fix(memory): bound shell startup env cache

* fix(memory): bound gitlab host context cache

* fix(memory): release removed ssh generations

* fix(memory): expire cloud refresh replay guards

* fix(memory): release retired plugin generations

* fix(memory): bound plugin log key retention

* fix(memory): bound automation authority generations

* fix(memory): bound native chat enrichment cache

* fix(memory): bound web session tracking generations

* fix(memory): bound codex credential absence paths

* fix(memory): bound WSL canonical path cache

* fix(memory): bound sparse checkout cache

* fix(memory): bound shared directory cache

* fix(memory): bound advertised URL scan snapshots

* fix(memory): bound automation manager cache

* fix(memory): bound web session reorder intents

* fix(memory): bound web session focus intents

* fix(memory): bound web session handoffs

* fix(memory): bound automation dispatch tokens

* fix(memory): bound host mirror waiters

* fix(memory): bound retained session activity

* fix(memory): bound retained session activity

* fix(memory): bound web session close intents

* fix(memory): bound cloud session cache

* fix(memory): bound WSL home cache

* fix(memory): bound SSH capability cache

* fix(memory): bound trust grant cooldowns

* fix(memory): bound WSL auth drain state

* fix(memory): bound Linear workspace credential cache

* fix(memory): bound local Git capability cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL preflight cache

* fix(memory): keep hot cache entries warm

* fix(memory): preserve generation fences across eviction

* fix(memory): close remaining eviction fences

* fix(memory): align evicted upstream generations

* fix(memory): trim successful capability probes

* fix(auth): retain expired refresh replay evidence

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-20 14:41:50 -07:00
buf0-bot[bot]andbench 5b8b01b206 fix(terminal): arm native-paste suppression on middle-click in mouse-tracking TUIs (#21834)
* fix(terminal): arm native-paste suppression on middle-click in mouse-tracking TUIs (#21762)

Orca's own middle-click paste path bailed out entirely whenever the pane was
in mouse tracking mode (Claude Code, Codex, ...), skipping preventDefault()
and never arming the #8993 native-paste suppression window. Chromium's
native Linux middle-click paste then landed unsuppressed alongside the TUI's
own PRIMARY paste from the forwarded mouse report, pasting the selection
twice.

Split pane lookup from the tracking-mode gate: any terminal pane target now
arms suppression and blocks the native paste, while only the paste-to-PTY
(and the propagation stop that would swallow the click before xterm can
report it) stays gated on mouseTrackingMode === 'none'.

* fix(terminal): address review nits on the #21762 middle-click fix

- Fix a mis-attributing comment: the suppression window (not preventDefault,
  which only helps on mousedown while Chromium's native paste fires on
  mouseup) is what swallows the duplicate native paste.
- Drop the now-unused getPrimarySelectionMiddleClickPane.
- Assert stopPropagation is/isn't called per tracking mode in the repro test.

---------

Co-authored-by: bench <bench@example.invalid>
2026-09-20 14:23:30 -07:00
Jinwoo Hong d86d5cbbee fix(mobile): settle browser dialogs on the stream that reports them, map taps through the page scale, and sweep the frame budget on the real encoder (OTA phase C, C6.7) (#21799)
* fix(browser): settle a page's dialog on the stream that reported it (OTA phase C, C6.7)

Chromium hands `Page.javascriptDialogOpening` to one CDP session and takes the
answer only from that session; a client attaching afterwards is told `No dialog
is showing`, and every renderer-bound command it sends first blocks behind the
dialog it was sent to clear. Measured on Chromium 1217, 2026-09-20.

`browser.dialogAccept` and `browser.dialogDismiss` went to the agent-browser
bridge, which is always a later client, so the reply never reached
`Page.handleJavaScriptDialog`: the page stayed blocked and its next dialog
never opened. The screencast is the session that reported the dialog, so it is
the session that answers it. With no stream live on the page the bridge path is
unchanged.

No RPC shape changes.

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

* fix(mobile): keep the browser dialog card until the page takes the answer (OTA phase C, C6.7)

The card was cleared on the press, before the reply was sent. A page blocked on
a dialog is still blocked until the host settles it, so the pane reported an
answer the page never got and left the user looking at a stream nothing could
move. The host's `dialogClosed` is what says the page took it, and that already
clears the card.

The port-pair case runs the pane against a host that only moves the page when
the dialog is answered: alert, OK, the card stays while the reply is in flight,
then the confirm raises its own card and resolves with the button's value.

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

* fix(mobile): map a tap through the page scale the frame was painted at (OTA phase C, C6.7)

Mobile view emulates a phone viewport, and a page with no `<meta name="viewport">`
lays out at Chromium's 980 px default and is scaled into it. The frame metadata
says so: `deviceWidth` stays the emulated width and `pageScaleFactor` carries the
ratio. The browser's input commands take page CSS pixels, so a tap sent in the
frame's own device space landed at that fraction of the aim — 41% on the phone,
which is how the C6.6 proof found it.

The frame geometry now carries the scale the frame was painted at, and both the
tap map and the finger-sized click radius go through it. Measured on Chromium
1217, 2026-09-20: `scrollOffsetX/Y` must not be added — the frame is the visual
viewport and the commands take viewport-relative coordinates, so a click sent at
`device / scale + scrollOffset` landed a screenful past its target while
`device / scale` hit it. Web view mode reports a scale of one and is unchanged,
and so is a frame whose metadata carries no usable scale.

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

* test(mobile): sweep the frame budget on the encoder the product runs (OTA phase C, C6.7)

The C6.5 sweep encoded through `canvas.toDataURL` while the pane's frames come
from `Page.startScreencast`, so the certification and the product were measuring
two encoders. The sweep now drives the screencast, over the same 143 viewports
and the same noise, through the same real shell.

The two encoders agree to within a thousandth of a byte per pixel, and the
screencast is the cheaper of them: across the 111 viewports the budget fits it
measured 0.543986 to 0.552964 bytes per pixel, against 0.54470 to 0.55351 from
`toDataURL`. `WORST_CASE_JPEG_BYTES_PER_PIXEL` stays 0.56, now stated as the
screencast maximum plus 1.3%.

So the encoder is not what made the C6.6 device proof drop 1 frame in 41 at
402x593 with the budget on. That frame needs about 0.5649 bytes per pixel, above
everything either sweep has seen, and nothing here reproduces it. The docstring
records that rather than folding it into the constant.

The frame is emulated at one device pixel per CSS pixel and the page carries a
viewport meta: headless Chromium composites at the DIP surface size whatever
`deviceScaleFactor` says, so without both the canvas is scaled into the frame,
the noise averages away and the sweep reads about 0.12 bytes per pixel.

Also records what C6 ruling 1 costs, in the pane's docstring: "never dark" holds
only for a page that produces some frame that fits. With every frame over the
cap the pane sits on its busy spinner over an unpainted viewport, which is the
budget's reason for existing. No code change for that.

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

* test(mobile): wait on the double buffer's flip, not on a digest (OTA phase C, C6.7)

The render check waited for some painted layer to carry a digest other than the
previous frame's. `applyFrame` writes the next frame's URI onto the hidden layer
as soon as the frame lands and only flips the opacity once the decode resolves,
so that predicate is true before the frame is on screen.

Measured with a MutationObserver over the style writes, 2026-09-20: the URI
landed 80.7 ms after the emit and the flip at 85.7 ms, a 5 ms window in which
the wait returns and the visible layer is still the previous frame. The check
usually outran it by the round-trip it spends reading the layers back, which is
why it failed once in CI (#21790, d1d9a59288) with the second frame's digest
equal to the first's and no console errors.

Both cases that used that predicate now wait on the flip itself: the visible
layer must become the other one, which is one opacity write at
`settleBrowserFrameLayer` and the behaviour under test rather than a proxy for
it. Asserting the exact layer rather than any change keeps a pane with nothing
visible from reading as a flip. The digest assertions stay, as the content check
the wait no longer stands in for.

With `settleBrowserFrameLayer` stubbed to skip the flip the case fails on the
wait, so it is still an oracle for the behaviour and not only for the timing.

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

* fix(browser): one dialog reply shape, and no dialog outliving its stream (OTA phase C, C6.7 round 1)

Round 1 findings 2 and 4.

The reply body forked: `{}` when a pane was streaming the page and the
agent-browser payload when one was not, so `orca browser dialog accept --json`
printed a different body depending on whether anyone happened to be watching.
Both paths now answer `{ accepted: boolean }`. The bridge's own body is not
forwarded, because it is whichever JSON that agent-browser version prints, which
is the thing a caller cannot rely on. Pinned by asserting the two paths answer
the same value.

A replacement stream can meet an open dialog: the last subscriber leaving stops
the page's session, and a later subscribe builds a new one, which is what
backgrounding and foregrounding the phone does. Measured on Chromium 1217,
2026-09-20, with the dialog up: detaching the session that reported it does not
complete, `Page.enable` on a fresh session does not complete, and
`Page.handleJavaScriptDialog` on it answers `No dialog is showing`. So carrying
`dialogOpen` across would report a success the page never got, and the
replacement's own start would hang before it could try.

The stream therefore dismisses a dialog still open when it stops, before
`Page.stopScreencast`, so no session is ever replaced with one outstanding.
Dismissing is the conservative answer for every dialog type including
beforeunload, and the automation path has taken it since `cdp-debugger-events.ts`.

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

* fix(mobile): say when a dialog answer did not land, and drop the sweep's inert emulation claim (OTA phase C, C6.7 round 1)

Round 1 findings 1, 3 and 5.

The sweep's `deviceScaleFactor` is inert and the docstring claimed otherwise.
Headless Chromium composites at the DIP surface size whatever the factor says,
so the sweep returns the same 0.543986 / 0.552964 to six decimals at 1 and at 3;
`Emulation.setDeviceMetricsOverride` is there to size the surface and nothing
else. What actually guards the measurement is the document's viewport meta,
without which the page lays out at 980 px, the canvas is scaled into the frame
and the noise averages away. That is no longer left to a comment: a case now
measures the same frame with the meta removed and reads under 0.3 bytes per
pixel, which is what proves the sweep's 0.5 floor is the thing that would catch
it.

The card cleared only on `dialogClosed`, but the request still suppressed its
error and timed out at 5 s, so a refused or timed-out answer left the modal up
with nothing said and a button that looked dead. A failed answer now marks the
open card, which keeps its buttons live for the retry. The updater is what stops
a late failure reopening a card the page has since closed.

The "never dark" note moves onto the component it describes, from the detached
block it was sitting in between the imports and the props type.

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

* fix(mobile): scale the wheel, answer a dialog once, and capture the sweep after the paint (OTA phase C, C6.7 bots)

Three bot findings on 6341321f44.

The wheel converted screen deltas with the frame fit and the zoom and left the
page scale out, so a flick on a page with no viewport meta delivered about 41%
of the scroll asked for — the same omission the tap had, one call site over.
Both call sites now go through one scale, named for the three factors every
screen-space quantity this pane sends has to carry.

`dialogOpen` stayed true until `Page.javascriptDialogClosed`, so two answers in
flight both sent `Page.handleJavaScriptDialog`. Chromium takes one per dialog:
the second is refused, or settles the page's next dialog unseen if it has
already been raised, and the rejection came back out of the RPC. One settlement
promise per dialog now, handed to duplicate callers, cleared when the dialog
closes or when the command fails — and only by its own dialog's generation, so
a late failure cannot disarm the next dialog's answer. The stop path reuses a
settlement already on its way rather than adding a second command. On the pane
the card's buttons go dead while an answer is in flight, keyed on the token of
the answer that armed them, so a reply landing after the page moved on writes to
neither card.

The sweep painted its noise before `Page.startScreencast` and accepted whatever
frame the deadline left it, so a capture taken before the paint committed could
have been measured as the cost of the canvas. The noise is now painted after the
screencast is running, through the same CDP session and behind two animation
frames, and only frames that arrive after that commit are used; none at all is
an error rather than a fallback. Re-measured across all 111 budgeted viewports:
0.543986 / 0.552964, unchanged to six decimals, so the constant stays where it
is and the capture is guaranteed rather than lucky.

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

* test(mobile): pin that a late dialog failure marks its own card, and quote the real case title (OTA phase C, C6.7 round 2)

Round 2 delta findings a and b.

The mis-stamp itself is already closed by the per-dialog token the bot fold
added: the failure updater skips unless the open card still carries the token of
the answer that armed it, and a dialog raised after that carries none. What was
missing is the sequence that proves it. The case answers the alert, lets the
page settle it and raise the confirm without the pane hearing the reply, then
times that reply out: the confirm keeps its own card and its live buttons. With
the token check reduced to the null guard it was reviewed with, the confirm is
stamped with the alert's failure.

The sweep docstring quoted a case title that does not exist, which is a pointer
that reads as a citation and resolves to nothing. It now quotes the real one.

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

* refactor(mobile): give the browser pane the one dialog-state type (OTA phase C, C6.7 bots)

pullfrog: the pane declared its own `BrowserDialogState` without `error` or
`pending`, and only structural typing let the hooks' wider setter flow into it,
so the fields the card renders were undeclared on the pane's own state. The pane
now imports the exported type and the local declaration is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 11:33:12 -04:00
Jinwoo Hong d5dc7b9cf8 feat(mobile): budget the terminal snapshot on serialized bytes and hold live output instead of ending the stream (OTA phase C, C7.3) (#21785)
* fix(mobile): budget the mobile terminal snapshot on the bytes it serializes to (OTA phase C, C7.3, ruling 1)

The desktop trims a mobile snapshot to 512 KiB of raw terminal text. A client
reading it through the page bridge measures the serialized event against a
640 KiB frame cap, and an ANSI snapshot is mostly ESC bytes, each of which JSON
spends six on. Measured here on a colour-dense 80-column screen: the raw budget
hands back 465,766 bytes that serialize to 669,268 — 102.1% of the cap — so
`deliver` answers `cancel(id, 'overflow')` and the terminal is dead before its
first live byte, with no recovery that does not reproduce it.

`terminal.subscribe` gains an optional `snapshotByteBudget`. A subscriber that
sends one is trimmed against the JSON its payload will really cost: the escaped
text, plus the metadata it cannot bound from its own side — a path, the OSC-link
list, the pending escape tail. A subscriber that sends none, which is every
socket client and every older page, keeps the raw byte rule exactly.

No negotiation, and none is needed: the field is additive and optional, so an
older desktop ignores it and trims as it always did. The page then still has a
snapshot over its cap, the shell still ends the stream with `overflow` (C0.3
stands), and the terminal renders its stream-error state rather than a blank
pane. The page derives the number from the cap less the event envelope rather
than writing it down, so a cap that moves takes the budget with it.

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

* feat(mobile): hold and coalesce terminal output instead of ending the stream on the window (OTA phase C, C7.3, ruling 2)

The shell's backpressure window ends a stream when the page falls 4 MiB behind.
That is right for a stream whose reader can survive a gap and wrong for a
terminal, whose reader cannot see the hole a dropped chunk leaves — and the
window does not wait for a page to go wrong. Measured by the design: the host
produces 70.3 MiB/s of JSON and real xterm applies 2.2 MiB/s, so an ordinary
`cat` crosses the window in 62 ms. Replayed here through the real ledger against
a page draining at that rate, a 5 MB transcript ends the stream after 85 of 107
chunks plain and after 40 of 107 under `grep --color`.

Keyed by method on the shell, since the page cannot pick its own window,
`terminal.subscribe` now holds what it cannot send, merges consecutive output in
escaped bytes under the frame cap, and delivers as the page acks. Nothing is
dropped: merging concatenates, and the only exit that loses bytes is ending the
stream, which the page is told about. Both transcripts now arrive whole and in
order, in 104 and 81 frames, with the largest frame at 622,551 bytes against the
655,360-byte cap.

It ends only on the two things that are not slowness: a page that has acked
nothing for 20 s, an order of magnitude above the 1.9 s a full window takes to
drain, and a backlog past 32 MiB, which at that drain is about 15 s of catching
up. Both reach the page as `overflow`, because the shell is the installed app
and its page comes from the desktop, so a reason the page's reader has never
heard of is a frame it drops rather than an end it acts on. Which one fired, the
coalesced-frame count and the peak pending bytes go to the diagnostic log, which
is the device proof's only oracle for any of this.

Every other stream keeps the byte window exactly, and an event over the frame cap
still ends any stream, terminal or not (C0.3). The landed window cases now name a
stream the window still governs, so the two rules are never read off each other.

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

* fix(mobile): narrow the event arm the backlog replay reads

A binary event carries no `payload`, so the tests-typecheck ratchet refused the
reach into it.

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

* refactor(mobile): narrow the snapshot serializer to the buffer source it reads

The changed-code casting gate refused the test's stub runtime, and it was right
to: a service-wide type for a function that calls one method is what made the
stub need an assertion. The parameter now says what it needs, and the fixture
path is no longer one a machine-path grep reads as a leaked local checkout.

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

* fix(mobile): measure the snapshot budget by building the payload, not by summing fields (OTA phase C, C7.3, ruling 14)

Round one summed the escaped text and four metadata fields. The payload a
bridged client assembles carries nine more — `kind`, `cols`, `rows`,
`requestId`, `displayMode`, `reason`, `seq` and both truncation flags — plus the
`type` and `streamId` it adds, the `serialized` key and the object's own braces.
So a snapshot this host accepted at exactly the budget, with
`truncatedByByteBudget` false because nothing had trimmed it, published over the
cap and the stream ended with `overflow` before a byte was painted.

Measured here on a screen sized to land exactly on round one's budget: the
published payload is 655,446 bytes against a 655,273-byte budget, 173 over, and
the frame it makes is over the 640 KiB cap by the same amount.

The metadata is now built by one function that `sendSnapshotFrames` and the
budget both call, and the budget stringifies the payload that function produces.
Nothing is summed and nothing is estimated, so a field added to the frame is paid
for by the budget the moment it is sent. Where a value is not yet known — the
truncation flags, and `seq` or `requestId` at a site that has not fixed them —
it is measured at the widest `JSON.stringify` can write it, which is a bound
rather than a guess, and forcing `seq` to a number also opens the three fields it
gates so those are counted too.

The budget therefore travels with the publication fields, because the payload
cannot be built without them.

On the page, the event envelope is now derived in one place in the protocol
module and read by both the snapshot budget and the shell's own merge budget, so
the two cannot drift; the page pins the number it sends and the host's cases name
that pin, since the two programs cannot import from each other.

The case that re-implemented the host's measure is gone: it could not have seen
this, because it was the same arithmetic twice.

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

* fix(mobile): arm a held terminal's silence clock only while something is pending (OTA phase C, C7.3)

The invariant is "armed implies waiting on the page", and round one broke it in
the one direction that kills: an ack re-armed the clock and the drain that
followed emptied the queue without clearing it. A terminal that had delivered
every byte and gone quiet — which is what a terminal does between commands —
would die on `overflow` twenty seconds later.

The clock is now synchronised after every change to the queue, so it is armed
exactly while something is held. A rule that only ever arms is a rule that only
ever ends more streams.

Red-first: with round one's arming, an idle stream whose queue has drained still
reports its clock armed, and firing it ends a healthy terminal.

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

* test(mobile): pin the held-stream cases the rulings name (OTA phase C, C7.3)

Six cases nothing covered. Two subscriptions on one shell keep separate backlogs,
so a busy terminal cannot end a quiet one. A stream the page unsubscribed mid-
backlog posts nothing after, and neither does one that has already ended, however
much was still held. A payload that is not output breaks a merge run and keeps
its place, because a resize is state the reader applies in order. And the budget
boundary is checked on the side that enforces it: a payload at exactly the number
the page asks the desktop for is delivered inside the cap, and one the cap cannot
hold ends the stream under C0.3.

The replay no longer acks unconditionally in its catch-up loop. That was the page
behaving better than a page can — it acks on reading frames — and it is what hid
the silence clock left armed over an empty queue. The held-stream cases close the
window on its frame count rather than on four megabytes of string work.

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

* refactor(mobile): give the event-envelope derivation its own module (OTA phase C, C7.3)

`bridge-envelope.ts` is at its line cap and is the protocol's schemas; what a
frame costs around its payload is a derivation over them, and two budgets read
it — the snapshot the page asks the desktop for, and the output the shell merges.
One module, so they cannot drift and neither file is pushed over its limit.

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

* test: narrow the budget fixtures instead of asserting them

The changed-code casting gate refused six `as NonNullable<...>` in the new
budget cases, and it was right to: a fixture that serialized nothing is a broken
case rather than a null to assert away.

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

* refactor: give the snapshot payload shape its own module (OTA phase C, C7.3)

`terminal-snapshot-publication.ts` crossed the root config's 300-line cap, which
mobile's own lint does not apply and CI does. The frame's shape and what it costs
a client reading it as one payload is a description the budget and the sender
both need, so it is the part that leaves.

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

* fix: empty a snapshot the budget cannot fit instead of posting it over (OTA phase C, C7.3, ruling 15)

Both trimming loops published the zero-row candidate whatever it measured, and
zero scrollback is not a small screen: a wide colour-dense viewport still carries
its 24 live rows. A capped subscriber could get one frame over its cap, end the
stream on `overflow` and paint nothing — worse than a blank terminal, because a
blank one repaints on the next byte of output and a stream that never opened does
not reopen.

Ruling 15: a budgeted subscriber gets that frame with its text emptied and
`truncatedByByteBudget` true, never over and never refused. The raw rule keeps its
fallback, so an older page and every socket client are served exactly what they
were before. Below the metadata the frame must carry there is nothing left to give
up, and that boundary is pinned rather than claimed away.

The renderer loop is the same walk reached by a different caller and had no test
at all; its runtime parameter is narrowed to the two methods it reads so a case
can stub it without a cast.

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

* fix(mobile): report what a held terminal stream did instead of calling it an outlived view

The backlog report had no branch in the reporter, so it fell through to the "a
view outlived its host" warn and every field it exists to carry was discarded.
The key made it worse: keyed by kind alone, one backlog per host was ever logged,
and a shell holds one stream per open terminal.

That report is the only oracle the coalescing rule has. Nothing crosses to the
page saying how much was held or how many frames its bytes arrived inside, and
both ways a held stream dies reach the page as `overflow`, because a reason its
reader has never heard of is a frame it drops. In production the two rules were
indistinguishable. They are now a line each, per stream.

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

* test: give the renderer fixture the source its serializer returns

`serializeRendererTerminalBuffer` answers `renderer`, and vitest does not
typecheck, so the stub's `headless` passed every run and failed the node
typecheck instead.

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

* fix: budget the frame the publication actually sends (OTA phase C, C7.3)

The budget and the publication were written out twice, five lines apart, and had
drifted at every site: a budget for `{kind:'scrollback'}` approved a frame sent as
`kind:'resized'` with a `reason` beside it, and the live module budgeted
`pending-output-overflow` while sending `renderer-mount-ready`. It held only
because the padded `requestId` and `seq` are absent from those frames and more
than covered the difference. Each site now builds one object and hands it to
both.

`displayMode` cannot travel that way and was a third under-measure nobody had
named: the subscribe flow re-reads it from the runtime after the snapshot is
serialized and before the frame is sent, so no caller can tell the budget which
mode the publication will carry. It joins `seq`, `requestId` and the truncation
flags as a field taken at its widest. The mode list resolves the constant to
`never` if the runtime gains a mode it does not carry, so a new one is weighed
here rather than found on a phone.

Red-first needed a second attempt: the first fixture had trimming slack, so three
extra bytes fit and the probe could not see the defect it was written for. The
case now budgets a fixed screen at exactly its `auto` measure, where the margin
is the whole of the test.

One figure for the overshoot everywhere, with its basis: 169 bytes over the
655,360-byte cap on a frame carrying an 8-character request id, 247 with a
24-character one. Three places said 169 and one said 173.

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

* refactor(mobile): delete two backlog guards no input can reach

Both survived mutation because neither is reachable, and neither became
reachable when I tried to write a case for it.

`next` narrowed the merge ceiling to one frame, but its only caller,
`drainTerminalBacklog`, has already narrowed it: the parameter is what one
payload may occupy, not what the window holds, so the second narrowing could
never change the answer. The parameter now says so and the class no longer needs
the frame size at all. The bound still lives in the caller and is still covered:
removing it there reds a delivery case.

The merge run also compared stream ids, but a backlog belongs to one subscription
and every `data` payload on it carries that subscription's single stream id, so
the comparison could not fail. The run still stops at anything that is not
output, which is reachable and pinned.

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

* docs(mobile): record the invariant the deleted stream-id guard rested on

The merge run compares no stream ids because it cannot need to: a backlog belongs
to one subscription and every `data` payload reaching it carries that
subscription's single stream id. Written down where the run is, because the thing
that would break it is a change made somewhere else — multiplexing two streams
onto one record would merge their output into one payload under the first id.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 10:19:41 -04:00
Jinwoo Hong f5d2d6e757 feat(mobile): carry browser screencast frames over the bridge as base64 (OTA phase C, C6.1) (#21758)
* feat(mobile): carry screencast frames over the bridge as base64 (OTA phase C, C6.1)

`bridge-screencast-binary.ts` landed in C0 as the page's half of the binary
lane and named C6 as the owner of the encoder that satisfies it. This is that
encoder, plus the host honouring `wantsBinary`: a subscribe that asked for
binary gets an `onBinaryFrame` on the native stream, and each frame crosses as
the envelope's `event.binary` on the same `seq` ledger as the stream's JSON
events, because the page acks by that count.

The base64 encoder is grouped rather than per byte or per `fromCharCode`
window. Its docstring carries the measurement, including the part that
contradicts the design note this came from: on V8 the per-byte form is the
fastest of the three, not the quadratic one, and the chunked form it was meant
to beat is the slowest. The grouped one is here because its cost does not
depend on how an engine ropes `+=`, and Hermes is what the shell runs.

No new opcode, no `v` bump, no negotiation added: `wantsBinary` is already in
the contract and is the negotiation. Over-cap behaviour is unchanged in this
commit — a binary event over the frame cap still ends the stream, which is what
C6.2 changes.

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

* feat(mobile): drop an over-cap screencast frame instead of ending the stream (OTA phase C, C6.1)

Measured at the pane's own request parameters, a screencast frame exceeds the
640 KiB envelope on a phone layout whenever the page will not compress: JPEG's
worst case is 0.545 bytes per pixel at quality 72, so mobile view mode at
780x1424 is 811,289 bytes, 124% of the cap. Ending the stream there blacks out
a browser tab for the life of the pane over one frame.

So the two kinds of event part at the cap. A JSON event that will not fit still
ends the stream with `overflow`, because its reader cannot see the hole it
would leave; a screencast frame is dropped and the stream lives, because the
next frame is one throttle interval away and the pane is still showing the last
one. Both are asserted side by side so neither turns into the other.

A drop leaves no other trace: the diagnostic beside it prints once per host, so
a stream shedding a frame a second and one that shed a single frame read the
same. The host therefore counts them per stream for the diagnostic and keeps a
session total, and the shell's dev facts carry that total — the surface that
already shows build state, with the line moved into its own module so what it
says is pinned rather than inferred from a template. The 12-character build
prefix it has always shown is unchanged.

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

* feat(mobile): name the binary screencast lane as a grant (OTA phase C, C6.1)

Ruling 5's negotiation, and the check it asked for first: no reader of a grant
is a closed enum, so there is no blocker and nothing an older page has to
tolerate. `BridgeGrantsSchema.native` and the shell's manifest reader are both
open string arrays, and the shell reader's own docstring already states the
degradation — a grant name a build does not know leaves that one route native
rather than refusing the bundle.

What does constrain the name is the host contract's `GRANT_NAME_PATTERN`: a
grant is one camelCase token or a `native.<domain>.<action>` verb with at least
two dot segments. So `browser.screencast` and `native.screencast` are both
refused, and the lane is `screencastBinary`. `screencast` alone would be wrong:
the page can already subscribe to `browser.screencast` and receive its JSON
events, and only the binary frames need the encoder.

Added to the shell's implemented set, which is the same list `init.grants.native
` offers, so a route declaring it is served by a shell that has the encoder and
left native by one that does not. No route declares it here; C7's session route
does.

The contract-side case is a characterisation pin, not a red-first one: the
pattern already admitted this name, and the test records that the two tempting
spellings are the ones it refuses.

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

* test(mobile): check the dropped-frame total through the bridge hook (OTA phase C, C6.1)

The hook gained a required `onBinaryFramesDropped` two commits ago and this
test kept calling it without one, so the tests-typecheck ratchet went red on
that commit — caught here rather than in CI because an exit code was read off a
pipeline's last stage instead of the script.

Fixed by wiring the callback into the probe rather than by a cast, and with the
case that makes the wiring evidence instead of types: a dropped frame raises
the total the screen receives, and the stream stays subscribed while it does.

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

* refactor(mobile): keep the dropped-frame counter with the ledger it belongs to (OTA phase C, C6.1)

Declared between a getter and a method, which is not where this class keeps
state: the subscription map is at the top and the counter is the same kind of
thing. Move only.

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

* fix(mobile): serve the binary screencast lane only to a route granted it (OTA phase C, C6.1)

Reported as a gap after C6.1's third commit and ruled on: the host honoured
`wantsBinary` from any page, so a route that never declared `screencastBinary`
could still make the shell encode base64 on its behalf. That is the hole
per-route grants exist to close — the same class as a route granted only
`navigate` and `storage` reaching the clipboard.

The rule now reads the session's resolved list, which is what its route
declared narrowed to what this shell implements, and is the same set
`init.grants.native` is built from. So the host offers the lane in `init`
exactly when it will serve it.

Ungranted is not a refusal. The subscription proceeds and its JSON events cross
as before, which is the silence every other grant gives at the call site; a
page that reads its own grants never reaches that state. Both branches are
pinned beside each other, and `grantsForRoute` is pinned dropping a grant this
shell does not implement — granted-but-unimplemented and never-granted arrive
at the host as the same absence, so its rule reads one case.

The grant name moves into the module that holds the rule reading it, so the two
cannot drift. `bridge-host.ts` is at 298 of its 300-line cap after this.

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

* refactor(mobile): move the page's stream-frame rules out of the host (OTA phase C, C6.1)

`bridge-host.ts` reached 298 of its 300-line cap, so the next main merge that
touched it would have crossed under CI pressure on someone else's PR. Split
deliberately instead, at the boundary the growth came from.

`bridge-host.ts` is the host's lifecycle and its dispatch. Opening a stream is
the only frame kind whose handling is more than one line of delegation — four
refusals and, since C6.1, the binary-lane decision — so it moves whole, and
`cancel` and `ack` move with it so all three stream frames are decided in one
place. The host's `cancel` arm still chooses between a stream and a request
where it always did: a page's `cancel` names one or the other, and splitting
that choice would leave half an arm in each module.

Counted without blank lines or comments, as the rule counts them:
bridge-host.ts 298 -> 270, and the new module is 59.

A pure move. No test changed and none was added, which is what makes the
existing suites the proof: 45 files and 745 tests green on the same assertions
as before.

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

* feat(mobile): report a page that asked for screencast frames it was not granted (OTA phase C, C6.1)

An ungranted `wantsBinary` is not a refusal on the wire, so nothing crosses
back: the subscription proceeds and its JSON events cross as they always have.
That left a page which did ask getting JSON for the life of the document with
no side able to say why. `notify-refused` has covered the equivalent notify
case since C0; this is the same shape for the one frame kind that lacked it.

The rule now answers a verdict rather than a boolean, because `not-asked` and
`ungranted` are the same answer for different reasons and only one is worth
reporting. So the decision and the report read one rule, and a page that never
asked stays silent — pinned, along with a granted route staying silent, so the
line cannot start firing on either.

The wire is unchanged and pinned unchanged: the case beside this one still
asserts one JSON event delivered and zero error frames.

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

* fix(mobile): reset the dropped-frame total with the host that counts it (OTA phase C, C6.1)

Round 1 on #21758, three findings.

The real one: the count is per host and the screen's copy was not. A rebuilt
host starts its own total at zero, so the screen kept the retired host's number
until the new one dropped a frame and then read *lower* — a falling count looks
like frames coming back, which is worse than starting over. The hook now
announces a fresh count as it builds a host. That also reports zero on the
first build, where the screen is already at zero and React bails out of the
render; the two hook cases pin that leading zero rather than leave it to be
rediscovered.

Two docstrings that described nothing: `BUILD_ID_PREFIX_LENGTH`'s stayed behind
when the constant moved to the dev-facts module and had drifted above
`failureMessage`, and `page-route-policy.test.ts` kept the docstring of the
test it replaced above the one that replaced it. Both deleted; the first's text
lives on the new module.

Red-first for the reset, checked against its final expectations rather than its
first: with the one line reverted both hook cases fail on the missing zero, and
both pass with it.

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

* fix(mobile): keep the dropped-frame total out of a production build's render path (OTA phase C, C6.1)

CodeRabbit's Major on #21758. The total went into React state on every dropped
frame in every build, and outside a development build the line that reads it
renders null — so an over-cap page re-rendered the whole shell screen up to ten
times a second for a fact nobody can see. Measured, not argued: five drops,
five extra renders.

Fixed at the seam rather than with a ternary at the call site. The dev-facts
module owns the line, so it now owns the number behind it and the rule that the
number is only state where something renders it. The screen holds no flag and
no counter; it asks for both and passes the reporter on. The reporter is stable,
so the bridge host is never rebuilt for it.

`isDevelopmentBuild` becomes a call rather than a module constant. A build flag
never changes at runtime so this costs nothing, and as a constant the branch was
unreachable to anything that did not set the global before the module loaded —
which is why the production case could not be written at the screen at all.

Also fixed, found while writing that case: the screen test's
`usePageHostSnapshot` double returned a fresh object on every render, so the
host effect's identity changed each time and the bridge host was torn down and
rebuilt on every render of the screen, settling every pending request with it.
The real hook holds the snapshot in `useState` and is stable. One object for the
file now. This was masking the fold under test — the count reset to zero on
every render — and every other case in that file was measuring a rebuild storm.

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

* perf(mobile): price a screencast frame before encoding it (OTA phase C, C6.1)

Round 2 on #21758, two lows.

The encode is a base64 pass over the whole image and the window decides whether
the frame can be posted at all, so deciding after encoding made a page that had
stopped acking pay for every frame the shell then threw away — the reviewer's
case is ten 300 KB frames against a closed window, 3 MB encoded and nothing
sent. The size is knowable without encoding: base64 is ASCII, so JSON escapes
none of it and the frame is its header serialized plus exactly the image's
encoded length. `encodeBridgeScreencastFrame` is now built from that header
rather than beside it, so the shape measured and the shape sent cannot drift,
and the window arithmetic is one rule read before the encode and again on the
frame that was.

Exact, not conservative, so the drop diagnostic still reports the whole frame
and the committed byte pin is untouched.

Red-first with the real encoder wrapped in a counter: window full, ten frames,
ten encodes before and zero after, with the drop count still ten. An over-cap
frame likewise goes from one encode to none. A third case holds the other
direction — two carryable frames still encode twice — so the fix cannot pass by
encoding nothing.

Second low: the dev-facts block sat outside the only `beforeEach` and left
`routeGrants` and `client` mutated, inert only because it runs last. The shared
setup moves to file level where the mutable dependencies actually live, resets
both, and a case at the end of the file pins it — deleting the reset fails
there and nowhere else, since nothing else runs after a case that mutates them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:19:00 -04:00
8dee68a8d1 fix(terminal): preserve Polish and Option-composed text in kitty panes (#21082)
* fix(terminal): preserve Option-composed text in kitty panes

Adapt the composition fix from #20579 and the input-source correction from
#20164. Extend coverage to every Polish letter, live setting changes,
associated text, and Chromium-to-PTY word entry.

Co-authored-by: yu.xia <yuxianice@163.com>
Co-authored-by: Alexandre Blause <alexandre.blause@gmail.com>

* test: guard native Korean IME against background launch

---------

Co-authored-by: yu.xia <yuxianice@163.com>
Co-authored-by: Alexandre Blause <alexandre.blause@gmail.com>
2026-09-20 00:28:25 -07:00
Neil fa4ea57871 fix(terminal): keep Pi input visible in open synchronized frames (#21708)
* fix(terminal): keep Pi input visible in open synchronized frames

* test(terminal): keep synchronized input fixture lint-clean

Place the existing SAFETY lint directive directly on the private xterm state assertion so the repository quality gate recognizes the reviewed test-only cast.

* fix(terminal): preserve startup parse callback

* fix(terminal): bound frame close after safety flush

* test(terminal): type startup callback fixture
2026-09-20 00:13:33 -07:00
OrcaWinandm4air e225b4b7eb Fix stale Codex usage after reset (#21748)
* fix(rate-limits): refresh Codex usage after reset

* fix(rate-limits): converge weekly Codex reset usage

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-19 22:22:16 -07:00
Neil b5b727bddb feat(composer): restore compact branch picker UX (#21741)
* feat(composer): restore compact branch picker UX

* fix(composer): address picker review feedback
2026-09-19 19:05:14 -07:00
84d827a6ab fix(daemon): pause producers when stream backlogs grow (#20947)
* fix(daemon): pause producers when stream backlogs grow

* fix(daemon): reset stream backpressure on socket replacement

* docs(daemon): point retention audit at current reproducer

* test(daemon): validate stream retention audit outcomes

* fix(daemon): bound the stream producer stall and leave a visible gap

Stream backpressure pauses a session's PTY with no deadline: the only
un-pause comes from the consumer draining, so a half-open peer that stops
reading without closing freezes the shell for the rest of the session.

Arm a 60s watchdog on the false->true stream-pause transition (not on the
re-assertions refresh() makes for neighbouring sessions). On fire, mark the
session stall-released: it becomes keep-tail droppable, its backlog is
thinned behind a dataGap, and the producer runs again. The existing dataGap
path makes the renderer restore that pane from the daemon's snapshot, so the
user sees the terminal jump to current rather than sit frozen. The mark
clears once the session's last byte leaves the daemon, restoring ordinary
pausing. Nothing here reports a process exit - loss of contact with a
consumer is not evidence about the child.

Also enable TCP keepalive on the stream socket so a genuinely dead peer
closes and onStreamDisconnected clears the pause.

* test(daemon): put each casting SAFETY: directive on one line

`oxlint-disable-next-line` covers only the line directly after it, so a
rationale wrapped onto a second comment line suppressed nothing and the
casts failed the changed-code quality gate. Drop the remaining JSON.parse
cast for an annotated binding.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:51:59 -07:00
921882619e fix: retire closed editor models from the app shell (#21178)
* fix: retire closed editor models from the app shell

* test(editor): use checked Monaco attachment calls

* Preserve bounded editor view caches when retiring closed models

* docs(editor): describe batched model retirement

* fix(editor): preserve cleanup work across registry replacement

* fix(editor): build editor model URIs with the file scheme

Monaco keys its model registry by `uri.toString()`, and both
`@monaco-editor/react` (via the `path` prop) and the closed-tab disposal
path built that key with `Uri.parse`. On Windows a raw path such as
`C:\repo\a.ts` parses as scheme `c`, which fails the scheme gate in
`modelService._schemaShouldMaintainUndoRedoElements`, so closed-file undo
history was dropped for every file at any size — not only the large files
the tradeoff note covers.

Add `toEditorModelUri`, the one filesystem-path -> model-key function,
built on `Uri.file` so the result always carries the `file:` scheme and
re-parses to itself. Route model creation, disposal lookup and the
still-open ownership comparison through it so all three agree; a
divergence there would dispose a model an open editor is still editing.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:51:55 -07:00
NeilandXiro The Dev ee354a35d7 feat(agents): add OpenCode 2 beta support (#21418)
* feat(agents): add OpenCode 2 beta support

Co-authored-by: Xiro The Dev <lethanhtrung.trungle@gmail.com>

* fix(opencode2): support current plugin lifecycle and session storage

* fix(opencode2): preserve lifecycle ordering and full session capture

* test(opencode2): cover setup event bridge

* test(opencode2): cover setup event bridge

* test(browser): satisfy anti-slop naming check

* test(opencode2): cover live form lifecycle

* fix(relay): preserve OMP config directory selection

* test(opencode2): avoid assertions in bridge fixture

* fix(rebase): retain OMP resume and fresh launch behavior

* test: align upstream OMP resume expectations

* test(opencode2): verify rejected form closes waiting state

---------

Co-authored-by: Xiro The Dev <lethanhtrung.trungle@gmail.com>
2026-09-19 17:49:03 -07:00
403c0881e1 Bound AI Vault transcript record assembly before allocation (#20963)
* fix(ai-vault): bound incremental transcript record assembly

* fix(ai-vault): skip one oversized record instead of dropping the session

An agent transcript record over the 10 MiB budget threw out of the JSONL
fold, so the whole session vanished from Agent Session History and from
search. A 10 MiB base64 image or a runaway tool result is ordinary.

The reader now discards the offending record up to its newline and keeps
folding. The in-progress record always starts at `consumedThrough`, which
is what makes both its running size and the resume offset past a discarded
span exact; an unterminated oversized tail leaves the cursor at the
record's start so a still-growing record is re-read rather than guessed at.
Skips accumulate on the resume point keyed by start offset, and the scanner
reports them as a per-session `notice` so nothing is silently lost.

The budget itself is unchanged.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:47:56 -07:00
f87359cda6 fix(runtime): persist acknowledged terminal tab retirement (#21020)
* fix(runtime): persist acknowledged terminal tab retirement

* test(runtime): drain tab retirement fixture writes before teardown

* fix(runtime): explain a refused workspace terminal close

The Sleep-workspace path threw the raw refusal enum ("stale-terminal") as an
Error message, which reaches a CLI user verbatim and a Sleep toast via
describeSleepFailure. Map each refusal reason to a sentence instead.

Also pins two behaviours that had no coverage: the user-visible outcome of a
republished stale-terminal refusal on the web client (the caller cannot tell it
from a real close), and the one-call-per-close invariant that keeps a successor
terminal alive.

The bounded close retry was NOT implemented: notifier.closeTerminalTab carries
only a tab id, so a second call destroys whatever successor took that id.

* test(runtime): build refusal fixtures without type assertions

The changed-code quality gate rejects new `as` casts. Replace the
branded-outcome cast with refusedMobileSessionTabClose, and model the
wire-skew reason as a decoded host answer instead of `as never`.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:38:05 -07:00
db7b57b846 fix(claude): enforce history window quota while reading (#21021)
* fix(claude): enforce history window quota while reading

* test: repair history quota audit dependency and CI import

* fix(native-chat): record why restart reconciliation leaves work unconfirmed

Two silent paths hid the cause of an unconfirmed submission. The reconciler's
bare `continue` on an `unknown` outcome dropped the reason it already carried,
and the transcript read swallowed its error, collapsing an oversize file and a
genuine read failure into the same verdict.

Log both. No control flow changes.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-19 17:25:36 -07:00
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