Commit Graph
8942 Commits
Author SHA1 Message Date
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
Neil 85a3ba6d42 fix(terminal): align CJK IME preedit spacing (#19367)
* fix(terminal): align IME preedit to terminal cell grid

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

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

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

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

* chore: regenerate xterm patch after rebase

* test(terminal): remove IME assertion lint findings

* test(terminal): avoid reflective IME fixture access

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

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

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

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

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

* perf(terminal): avoid duplicate renderer risk scans

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

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

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

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

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

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

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

* fix(settings): register collapse unchanged search entry

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

* fix(editor): satisfy diff viewer line budget

---------

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

* fix(review): keep diff filenames visible

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

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

* fix(plugins): fence stale activation after uninstall

* chore: allow durable plugin uninstall audit evidence

---------

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

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

Fixes #18117

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

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

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

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

Fixes STA-6173.

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

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

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

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

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

Addresses review on the STA-6173 hardening.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(runtime): reconcile session tab bootstrap integration

* fix(runtime): preserve terminal wake launch options

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

* test(session): remove obsolete typecheck suppression

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

* refactor(runtime): remove inert snapshot recovery wrapper

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

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

---------

Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-19 14:30:25 -07:00
Neil f016d38e9c test(e2e): prove Pi EOF removes sidebar agent row (#21722) 2026-09-19 13:00:46 -07:00
Jinjing 3cd674fdff Make jump-palette selection state higher contrast (#21718)
- Increase light-mode contrast: foreground mix 12%→13%, ring 18%→19%
- Extend selectors for .jump-palette-command scope and add class
2026-09-19 12:13:42 -07:00
Neil b6b974cb82 fix(terminal): clear stale agent identity after shell exit (#21714)
* fix(terminal): clear stale agent identity after shell exit

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(omp): check generated model metadata types

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

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

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

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

* fix(omp): preserve exact reported model selectors

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

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

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

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

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

* test(omp): keep renderer test type explicit

---------

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

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

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

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

No frame kind is added and no protocol version moves.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two rulings, one mechanism.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Discover current Devin ATIF exports alongside legacy transcripts

* Recognize drawn geometry in the browser markup contract test

* Deduplicate Devin exports across transcript roots

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

* Align OMP integration tests with recorded-path resume

* fix: update scan benchmarks and await relay environment test

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-19 03:32:12 -07:00
Neil ff934256ae fix(omp): retain recorded transcript paths when resuming (#20634)
Based on the resume-locator proposal in stablyai/orca#16276 by @CodeHourra. Retains UUID-based ownership and existing reattach behavior.
2026-09-19 02:02:50 -07:00
Neilandunknown 4dec64d533 feat(source-control-ai): support OMP generation (#20624)
* feat(source-control-ai): support OMP text generation

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

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

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

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

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

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

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

* test: align Source Control AI registry contracts with OMP

---------

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

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

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

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

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

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

* fix: scope mirrored tab retractions to owning host panes

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

* fix(sidebar): retain remote workspace attribution after tab hydration
2026-09-19 01:49:50 -07:00