Commit Graph
8878 Commits
Author SHA1 Message Date
Neil eecd30bf0e fix: report missing Antigravity hook scripts 2026-09-19 01:09:54 -07:00
Neil 49a85bfad5 fix: refuse Antigravity readiness while host is unverifiable 2026-09-19 01:04:19 -07:00
Neil dfbb928816 fix: use current Antigravity screens for waits and delivery 2026-09-19 00:58:37 -07:00
Neil 9fc2c5bea6 fix(antigravity): validate the visible composer before resolving adopted waits 2026-09-19 00:38:41 -07:00
Neil cae60dafc2 fix(antigravity): gate configured models on remote runtime support 2026-09-19 00:26:21 -07:00
Neil a94b345313 fix(antigravity): discover current source-control models and use CLI defaults 2026-09-19 00:16:57 -07:00
OrcaWin 7063c2cbdd fix: read crash diagnostics without loading whole dumps into memory
Read crash diagnostics incrementally to avoid whole-dump memory spikes.
2026-09-18 23:39:52 -07:00
OrcaWin 8812534335 fix(claude): stream transcript ancestry proofs
Stream transcript ancestry proofs without loading whole histories into memory.
2026-09-18 23:39:16 -07:00
Neil d966927013 fix(omp): submit large prompts in one PTY frame (#21573)
* fix(omp): join prompt submit with large paste

* test(omp): cover joined submit timing

* ci: rerun PR checks after timing test fix

* test(omp): acknowledge joined submit activity
2026-09-18 23:35:45 -07:00
Neil e8a956e833 fix(remote): reassert terminal geometry after reveal (#21568)
* fix(remote): reassert terminal geometry after reveal

* test(remote): expose layout queues through fixture

* test(remote): narrow layout target assertion
2026-09-18 23:35:05 -07:00
Neil 2bf538a4e1 fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see (#21436)
* fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see

A local terminal-artifact grant pinned the file as `dev:ino:nlink:size:mtimeMs`.
On Linux every one of those can survive an unlink+recreate: ext4 reuses the
just-freed inode (measured: 100% of the time), nlink and size are unchanged for a
same-size replacement, and the mtime clock is tick-quantized to 1ms, so a swap
inside one tick produces a byte-identical identity string. The grant then served
the attacker's bytes as if nothing had changed.

Local grants now also pin a sha256 of the artifact's content, taken from the same
handle as the stat so nothing can swap the file between them, and every local
read, preview and write re-checks it before returning or committing content.

The stat identity string itself is unchanged: the relay recomputes it verbatim to
honour `expectedStatIdentity`, so its format is a wire contract. Remote grants
keep the stat-only check and are untouched.

This is also the mechanism behind the intermittent
`orca-runtime-files-terminal-artifact-io.test.ts` failure on
`rejects stale absolute terminal artifact previews before returning changed
content`: it replaces an 8-byte artifact with 8 different bytes, so whenever the
two writes shared a 1ms tick the product genuinely could not tell them apart.

* docs(runtime): record what the terminal artifact grant checks do not close

The digest makes the same-size swap detectable; it does not make the sequence
atomic. A reader arriving at the access module would reasonably assume otherwise,
so write down the measured limits of the stat identity, why the identity string
cannot change, and the four windows that stay open — the write path's surviving
rename() gap above all.
2026-09-18 23:33:21 -07:00
Jinwoo Hong e49b3aa0bd fix(relay): carry the signed-out reason when the broker's own renewal notices the session loss (#21562)
The relay control socket closes with the reason `signed-out` so the cell can
tell paired phones to sign in rather than reporting a bare "host offline".
Only the auth coordinator computed that reason. The broker's renewal tick
called `closeNow()` with no reason when its token refresh came back empty, and
`closeNow` is idempotent, so whenever that tick observed the lost session
first — the common case for a session revoked or expired while Orca runs — the
coordinator's later `closeNow(SIGNED_OUT)` was a no-op and the cause never left
the desktop.

`refreshAccessToken` now returns a refusal that carries the reason the
coordinator already derives, and both close paths read it from one place. A
refusal for any other cause still names nothing: a present-but-unentitled
desktop is signed in, and a superseded refresh leaves the close to whoever
superseded it.
2026-09-18 23:56:00 -04:00
Neil 289a8bf1ce feat(settings): confirm before stopping running terminals (#21569)
* feat(settings): confirm before stopping running terminals

* fix(settings): localize close confirmation search keywords

* ci: rerun PR checks after localization fix

* fix(settings): add search keyword translations
2026-09-18 20:52:22 -07:00
Neil b6f6122bcd fix(terminal): preserve idle Linux IME direct commits (#21567) 2026-09-18 20:51:30 -07:00
Neil b69bc6d5b1 fix(terminal): preserve escape tails in renderer snapshots (#21578) 2026-09-18 20:49:59 -07:00
c34b944136 feat(github): bind projects to a specific gh account (#13664)
* feat(github): bind projects to a specific gh account

Adds per-project `Repo.ghAccount` so repo-scoped gh calls (create-worktree
issue/PR search, work items, hosted-review reads and mutations) run as the bound
account via ephemeral child-env token injection instead of the globally active
gh login. Multi-account resolution is capability-gated (gh >= 2.40) and fails
closed when the bound account or host is unavailable; Project View stays
ambient by design.

Repository settings gains a section for selecting or clearing a keyring-backed
account (shadcn `Select`), with mixed-version "not enforced" handling for older
remote runtimes. Attached `-Rhost/owner/repo` forms are covered by the host-drift
guard and its tests; es/ja/ko/zh catalogs carry the section's strings.

`getLocalProjectGhExecOptions` centralizes the binding lookup so every gh
execution path picks it up, including the Electron `hostedReview:*` handlers
that previously stayed on the ambient login. `gh auth token` (a keyring read)
is exempt from the rate-limit breaker gate so a tripped bucket cannot turn a
bound-token resolve into a false "unavailable".

The `ghAccount` update field and the two binding RPC methods live in the shared
RPC params contract; the generated catalog is regenerated.

Fixes #13612

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012B3QEP5iP4WGGEpPLtkHqA

* fix(settings): make GitHub account refresh secondary

* fix(github): satisfy strict casting quality checks

* test(rpc): use runtime fixture for repo binding

* fix(github): preserve project account for PR worktree lookups

* test(rpc): avoid incomplete runtime settings fixture

* fix(i18n): add GitHub account refresh label

* fix(i18n): refresh runtime required catalog

* fix(windows): preserve mobile patch bytes

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 20:39:52 -07:00
Neil 545f526c31 refactor(editor): land shared Markdown scanners on main (#21565)
* refactor(editor): introduce shared Markdown code scanners (#21554)

* refactor(editor): add shared Markdown scanners

* test(editor): verify standalone scanner boundaries

* fix(editor): preserve line endings and fenced code boundaries

* fix(editor): keep bare dash lines out of table scanning
2026-09-18 20:04:31 -07:00
Neil 2038376d8e fix(terminal): a park must not discard the only copy of a remote pane's scrollback (#21285)
* fix(terminal): keep a client copy of a parked remote pane's scrollback

A remote-runtime pty's bytes never transit the client's main process, so the pane's
xterm buffer is the only client-side copy. The ordinary cold-park unmounted that pane
without capturing it, licensed by TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY — a static
build string that says nothing about whether the host retained this pty's buffer. On
reveal, a host that answers 'no-serializable-buffer' (or stays silent past the request
timeout) collapses to a null snapshot and the pane paints blank: tabs and splits survive,
the scrollback is gone.

Capture before every park, not only the retention-budget force-park, so the reveal has a
copy to replay when the host cannot answer. An unverifiable host answer is not proof the
pane was empty; keep the buffer, never discard it.

Adds ORCA_E2E_FORCE_REMOTE_TERMINAL_SNAPSHOT_UNAVAILABLE so an e2e can reproduce the
host-retains-nothing state, mirroring the existing forced-truncation lever.

* test(terminal): prove a parked remote pane survives a host that answers nothing

The oracle is a token the test types into the terminal before the park and the fixture
echoes back. Nothing replays stdin, so a respawned command cannot reproduce that line —
only the pre-park buffer can. An earlier argv marker passed vacuously for exactly that
reason.

The control ('host retains the buffer') is insensitive to the fix and fails if the harness
never parks, never reveals, or never echoed the token, so the regression case cannot be
green for a harness reason.

* refactor(terminal): validate the paired host terminal RPC shape instead of casting it

The merge-commit consistent-type-assertions gate flags every new `as`. Two were fixture
shapes that a type annotation states directly, and the third hid an unchecked RPC payload —
readCreatedTerminalTab now fails with the shape named rather than surfacing later as an
undefined surface id.

* fix(terminal): let a park capture survive an unhydrated repo catalog

Reading state.repos unguarded threw out of the cold-park effect whenever the catalog was
absent, which would break parking itself. Capture is best-effort evidence; an empty catalog
also fails open in shouldPreserveTerminalScrollbackBuffers, the safe direction for a park.

* docs(terminal): pin why the two unhydrated-catalog fallbacks point opposite ways

shouldPreserveTerminalScrollbackBuffers fails open toward 'remote' because a worktree wrongly
judged local parks with no copy at all. worktree-runtime-owner.ts resolves the same unhydrated
catalog to 'local', which is safe there and would be data loss here. A reader pattern-matching
'fail open' across the two gets one of them backwards.

* fix(terminal): keep a parked pane's scrollback across a reconnect merge

The direct-SSH pull replaces a replaced tab's layout wholesale, and a park capture does not
bump tab.generation — so a just-parked tab is not in locallyPreservedTabIds and the only
client-side copy of its remote scrollback went with the layout it replaced. That is the same
data loss this branch already fixes, one layer down, and it is the layer that decides whether
the fix survives the app update the user actually performed.

Carry the client's leaf-keyed scrollback into the host's layout, filtered to the host's own
root leaves. Structure stays the host's verbatim, so a split it added while we were away still
wins and a leaf it retired still drops its bytes. Local wins a conflict: neither copy is then
the only one, but remote-wins would overwrite the tail captured since the last upload and
propagate that backwards on the next replace-session patch.

Not a generation bump: the pane key is `${tab.id}-${tab.generation}`, so bumping would remount
the pane and destroy the very buffer the capture just serialized, lift the recovery-storm
ledger ceiling, and let a stale local ptyId win through preserveNewerLocalTerminalFields.

* fix(terminal): carry a parked pane's scrollback through the mirrored-layout rebuild

Found in review of this PR by rc-ssh-remoting. chooseRemoteTerminalLayout rebuilds a
mirrored tab's layout from the host's picture and never carried buffersByLeafId or
scrollbackRefsByLeafId forward, though it already receives existingLayout. The host
publishes no scrollback of its own, so ANY session-inventory frame landing between park and
reveal dropped the only client-side copy: the rebuild is bufferless, terminalLayoutEqual
compares buffers so the write is not bailed out, and apply-terminal-records assigns it
wholesale.

Measured before the fix: 336 bytes captured at park, 0 after one forced frame, blank pane on
reveal. After: 411 bytes survive the frame and the reveal repaints.

The e2e passed either way because no frame happened to land in its window, so it was not
covering the destroying event. It now forces one inside the park -> reveal window and asserts
the capture survives it.

An identical fix was written and reverted earlier in this branch as 'no measurable effect' —
that measurement ran on a harness deleting the client profile between launches, so nothing
downstream of persistence could register. It was never actually tested.

* feat(session): add a local-only home for ordinary-park scrollback

localOnlyScrollbackByTabId is a top-level session field, tabId -> leafId -> buffer, that never
rides the remote projection: exportRemoteWorkspaceSession is an explicit allowlist of named
top-level fields, so a new one is omitted for free, whereas anything added to
TerminalLayoutSnapshot is copied whole. It is also outside the two records the mirrored-tab apply
rewrites, so a host inventory frame cannot wipe it.

Registered in every exhaustive session registry ('tabKeyed'), hydrated and scoped like the layout
map, dropped with its tab on close/removal/purge/repo removal/mirrored retirement, copied on profile
transfer, emitted by the incremental patch builder, and capped by pruneLocalTerminalScrollbackBuffers
alongside the shared home — with a per-home test so an uncapped path cannot go unnoticed.

Known ceiling, not widened here: the field routes through the partition router that falls back to
'local' when the repo catalog is unknown at write time (#21295).

* fix(terminal): keep ordinary-park scrollback off the upload, and read both homes through one resolver

The ordinary cold park fires on every workspace hide. Its capture now splits: structure (root,
ptyIds, titles) stays in the shared layout, bytes go to localOnlyScrollbackByTabId. Force-park,
hibernate, sleep and shutdown keep writing buffersByLeafId, because that copy is what a second
desktop cold-restores from; a shared capture clears the local copy so the two homes never hold two
versions of one leaf.

resolveLeafScrollbackBuffers is the only read across the two homes (local wins a conflict: it is
the later write by construction). restoreTerminalPaneLayout no longer reads buffersByLeafId
directly, the capture's merge prior comes from the resolver, and the post-replay release covers
both homes.

Measured with the projection at 20 tabs x 2 panes at the per-leaf cap: the shared-layout shape
exports ~22 MiB per replace-session; the local-only shape exports the bufferless baseline.

* test(sync): pin that the mirrored rebuild carries the client scrollback refs

The carry-through added in f210dece83 keeps scrollbackRefsByLeafId for leaves the host still
names (a ref is the only pointer to a local scrollback file), so the rebuilt layout equals the
stored one and the write bails. The old assertion expected the refs to be dropped and has been red
on this branch since that commit.

* test(e2e): assert where a park's bytes land, and re-point the inventory-frame check at the force-park

Once ordinary parks stop writing buffersByLeafId, the existing survivedInventoryFrame assertion
passes trivially — there is nothing in the layout to wipe. The ordinary scenarios now assert the
store-level upload contract (bytes in the local-only home, shared home empty) and that the local
home is out of a host frame's reach; a third scenario reaches a force-park (host without paired
parking, client retention limit 1) and asserts the shared capture survives the forced frame, which
is where the mirrored-layout carry-through is load-bearing.

* test(e2e): leave the force-park reveal out of the assertion instead of matching it loosely

expect.any(Boolean) reads as a check but cannot fail. The force-park reveal is served by the host
tail on a host without paired parking, so it is logged for diagnosis and the assertion carries the
three fields that are deterministic on this topology.
2026-09-18 19:49:38 -07:00
Neil 56e5cd5e34 fix(editor): render CJK-adjacent emphasis in Markdown preview 2026-09-18 19:15:45 -07:00
Neil d8cfe374a3 refactor(usage): share attribution and scope filtering
Readiness checklist review: PASS. No proven release-blocking findings. Codex and usage tests: 116 passed; all required CI checks green.
2026-09-18 19:14:38 -07:00
Jinwoo Hong ac4dc6599b feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

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

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

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

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

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

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

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

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

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

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

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

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

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

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

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

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

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

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

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

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

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

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

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

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

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

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

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

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

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

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

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

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

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

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

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

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

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

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

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

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

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

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

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

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

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

* style(mobile): format the web shell route entry

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

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

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

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:53:58 -04:00
Jinwoo Hong 084e101328 fix(session-search): keep the title filter while full-text search is off (#21550)
* fix(session-search): keep the title filter while full-text search is off

The search box switched to index search the moment the query was non-blank,
so on a computer with indexing off the panel showed only the consent card and
hid every session. Derive the mode once in useAiVaultPanelSearch: while local
consent is pending the box stays the legacy title filter and the consent card
becomes an offer above the filtered results.

* test(session-search): cover the legacy title filter while indexing is off

The panel test fails on the pre-fix code with no session rows rendered.

* refactor(session-search): drop Not now and name the query flag queried

The dismiss button's only remaining job was wiping the user's live title
filter. Keep localConsent and expose queried so the panel reads one flag per
fact, and assert the post-enable and empty-box states.

* refactor(session-search): guard first and name the search-mode flags for what they mean
2026-09-18 21:45:43 -04:00
Neil cd81725d70 feat(terminal): configure URL click and middle-click behavior (#21438)
* feat(terminal): configure URL click behavior

* fix(i18n): include terminal link setting title

* fix(i18n): localize terminal click controls

* fix(settings): update terminal URL click title
2026-09-18 17:31:06 -07:00
09073086a8 feat(terminal): inline images via @xterm/addon-image (perf-first) (#19512)
* feat(terminal): inline images via @xterm/addon-image, perf-first

Add opt-in inline terminal images (SIXEL, iTerm2 IIP, Kitty graphics)
through @xterm/addon-image, designed to keep idle terminals unaffected.

Performance:
- The addon (base64-inlined wasm decoders + protocol handlers) loads off
  the boot critical path via a deferred loader that mirrors the WebGL
  addon: primed after first paint only when the setting is on, read back
  synchronously at attach, with a 3-attempt cap so a transient failure
  never disables images for the session and a missing chunk never
  refetches per pane. renderer-boot-graph guards against eager import.
- enableSizeReports:false so the addon never sets windowOptions and
  double-answers Orca's own CSI 14t/16t responder.
- Perf-tuned decode/storage limits (storageLimit, sixel/iip/kitty size
  caps) in one place.

Correctness:
- Orca's DA1 handler wins over the addon's (last-registered-first), and
  the default DA1 response never advertised Sixel (;4), so DA1-detecting
  tools (chafa, img2sixel, viu, timg) never emitted it. The winning
  handler now appends ;4 while the setting is on, resolved per query so a
  live toggle changes the next DA1; idempotent against the ConPTY
  response that already lists it.
- ORCA_IMAGE_PROTOCOL=kitty is exported to spawned shells (local, daemon,
  relay/SSH) and forwarded across the WSL boundary, so image-capable
  agents can pick an encoder. Unknown image sequences are swallowed by
  xterm when the addon is detached, so this never garbles output.
- Settings toggle (default on) gates rendering and DA1 advertisement.

Cross-checked against community PRs #7775, #11706, and #19201 at the end;
credited below.

Co-authored-by: s546126 <s546126@users.noreply.github.com>
Co-authored-by: XRX193 <XRX193@users.noreply.github.com>
Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com>

* fix(terminal): bound inline image memory and classify Kitty replies

* fix(terminal): bound image decode and release image resources on cleanup

* fix(terminal): address image addon review feedback

* test(terminal): stub setPaneInlineImagesEnabled in appearance manager fakes

* fix(terminal): evict unplaced kitty payloads before displayed images

Byte-budget eviction dropped the oldest transmitted blob regardless of
placement, so a new upload could erase a visible image while abandoned
blobs still held budget. Unplaced payloads now go first and displayed
ones only when that is not enough. The incoming image is always stored,
so an oversized one overshoots the cap by one payload instead of being
dropped after the protocol already acked OK.

* fix(terminal): gate DA1 Sixel on real addon attachment; claim SSH image spec in CI

- DA1 advertised Sixel from the setting alone, so a pane whose lazy addon
  chunk was still loading (or had failed all three attempts) told
  feature-detecting tools to emit DCS that nothing could render. Track the
  attached decoder per terminal and require it before setting the ;4 bit.
- tests/e2e/terminal-inline-images-ssh.spec.ts was Docker-gated but claimed
  by no lane runner, so pr-e2e-gate-contract failed and the spec would have
  self-skipped green forever.
- Reject non-positive PNG IHDR dimensions before decode: they are parsed with
  signed shifts, so a dimension >= 0x80000000 came back negative and slipped
  past the pixel-limit comparison.
- One resolveTerminalInlineImagesEnabled() for the default-on setting; the
  four call sites mixed '?? true' with '!== false', which disagree on null.
- One readInlineImageResources() walk of the addon internals instead of two
  copies that could drift against the patched dependency.
- Isolate the deferred-attach drain per pane; make the zoom-invariance and
  backing-storage e2e assertions fail when the feature is dead.

* refactor(terminal): one lazy xterm addon loader for webgl and image

terminal-image-addon-loader was a structural clone of the webgl one — same
memo, attempt cap, and .then(ok,err)-clears-memo recovery. Both now wrap
createLazyXtermAddonLoader; each keeps its literal import() specifier so the
bundler still splits the chunk (verified against a fresh build: addon-image
stays out of the boot graph).

* refactor(terminal): name openTerminal's addon flags; pin image addon limits

Two adjacent optional booleans could be swapped without a type error once
inline images added the second one.

* docs(terminal): state the real per-pane image ceiling; drop test ordering dependency

storageLimit:32 reads like the pane's budget but keys three pools — decoded
pixels, retained encoded Kitty blobs, and pending WASM decoders — so the worst
case is ~98 MB per pane with no cross-pane governor. Say so at the constant.

pane-inline-images.test.ts's deferred case needed to run first; it now takes a
fresh module instead, and the rest prime in beforeAll. Verified by running the
file with that test moved last.

* fix(terminal): satisfy rebased static analysis gate

* fix(terminal): complete casting gate cleanup

* fix(terminal): recover failed image addon loads

* fix(terminal): bound image decoder allocations

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: s546126 <s546126@users.noreply.github.com>
Co-authored-by: XRX193 <XRX193@users.noreply.github.com>
Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 16:32:49 -07:00
NeilandVincent 9ebfd2e8ae Reapply "feat(composer): choose a base ref in the New Workspace composer" (#21536) (#21543)
This reverts commit a7b9ea5995.

Co-authored-by: Vincent <47273853+Tkotm76@users.noreply.github.com>
2026-09-18 16:00:26 -07:00
Neil a7b9ea5995 Revert "feat(composer): choose a base ref in the New Workspace composer" (#21536) 2026-09-18 14:58:38 -07:00
Jinwoo Hong 3336933cc8 fix(orchestration): list worker Dispatches newest first and warn when the page truncates (#21523)
* fix(orchestration): list worker Dispatches newest first and warn when the page truncates

`worker-list` paged `ORDER BY d.rowid ASC` with a 100-row cap, so a Run with
more than 100 Dispatches answered with its OLDEST 100. The workers a coordinator
had just started, and the rows carrying `projection.attention.requiresAction`,
were on a page nobody fetched, while `counts` and `page.total` covered the whole
Run so the receipt read as complete.

One ordering, flipped: the detail query and the terminal-state scan it pages by
both order `d.rowid DESC`, and the cursor fence walks down (`d.rowid < anchor`).
The snapshot fence is unchanged — `d.rowid <= snapshot` still means "nothing
created after the first call".

When the page truncates the receipt now carries a `warnings` string, the same
shape `worker-output` already uses, alongside `page.hasMore`. Text output keeps
its `More: --cursor` line and prints the warning through the block it already
had for partial-host errors.

Refs STA-7861

* fix(orchestration): make the worker-list truncation warning true on every page

The warning said "Showing the N newest of T Dispatches" unconditionally, but
`hasMore` is true on every page except the last, so page 2 of a 300-Dispatch Run
claimed to be the newest 100 while showing rows 200..101. This PR exists because
a receipt read as complete when it was not; that warning shipped a receipt that
read as the newest page when it was not.

The page count and the ordering are separate facts, so state them separately:
"Showing N of T Dispatches, newest first; more are on later pages." True on page
one and page N alike, no extra state. The 105-row case only ever reached the
last page, where `hasMore` is false, which is why it missed this; a new case
walks 6 Dispatches at `--limit 2` so a page that is truncated AND not page one
is covered.

Also: the `worker-list` --help note and the recovery-and-cleanup reference still
described the oldest-first contract; both now say newest first. The snapshot
test is renamed to the property it actually proves — under DESC a later insert
is unreachable by arithmetic, so what the `d.rowid <= snapshot` fence still
earns is pinned `page.total` and `counts`, not row exclusion. The continuation
comment says "below the anchor" next to `d.rowid < ?`, and the two SAFETY
rationales now say what they are: an unchanged cast the gate flagged because the
diff moved inside its span.

Refs STA-7861
2026-09-18 17:38:22 -04:00
Jinwoo Hong c5733e812a fix(opencode-usage): count cache-read tokens (#21522) 2026-09-18 17:07:22 -04:00
Jinwoo Hong 73a58bd21a feat(session-search): resolve Workspace and Project scope on the host (#21509)
* refactor(session-search): move the AI Vault project key to shared

The host must spell a project key exactly as the client does, so the two
sides share one function instead of two copies that can drift.

* feat(session-search): add a scope identity to the search request

The panel cannot keep translating a project into one path per worktree: a
repo with 580 of them exceeds the 64-path cap and the search fails outright.
The request now carries the scope's identity instead, and a host acknowledges
the scope it resolved so a client can tell a scoped answer from an old host's
unscoped one.

* feat(session-search): resolve a scope identity on the host that answers

Every entry point already funnels into searchSessionService, so the identity
becomes paths there once: native, WSL, SSH and relay hosts cannot disagree.
A host that does not know the workspace or project answers scope-unknown
rather than widening the search to everything it has.

* test(session-search): pin how a host resolves a scope identity

Covers prior paths, a workspace another now claims, folder workspaces, a
custom worktree base path, flat placement where the global root belongs to
every project, and the 580-worktree fold the panel's path list could not do.

* fix(session-search): type the scope store by what the catalog reads

A full Repo/Project/ProjectHostSetup requirement forced test stores to stand
up rows the catalog never looks at.

* feat(session-search): send the scope identity from the panel

Workspace and Project name what to narrow to; All sends nothing. A host that
answers a scoped search without acknowledging it is reported as needing an
update, and none of its hits are shown, because they are not this scope's.

* test(session-search): pin the new-client-against-old-host skew

An old host strips the identity and answers with every session it has, and
the answer is well-formed. The missing acknowledgement is the only evidence,
so the merge drops those hits and names the host instead.

* test(session-search): pin the identity and acknowledgement across every entry point

IPC, the runtime RPC method, the relay handler and the shared remote client
each carry the identity out and the acknowledgement back, and the relay -- which
has no repo catalog -- reports the scope rather than widening the search.

* fix(session-search): acknowledge the scope on an all-computers merge

The merge built its results without the acknowledgement, so the renderer read
it as an old host, dropped every hit and asked for an update. That is the
default path: the panel defaults to Workspace and the host scope falls back to
All. Per-host skew is still reported through `hosts`.

Host-resolved paths no longer travel in `filters.scopePaths`. That field is
capped at 64 for the clients that write it by hand, and the scanner child
re-parses the request with the same schema -- so a project whose worktrees do
not share one managed directory failed at 65 paths with "not ready". They ride
beside the request now, where no wire cap applies.

Managed directories come from buildKnownOrcaWorkspaceLayouts, so a workspace
root the user has since moved away from is covered too.

A workspace identity is resolved through this host's own worktree registry
rather than the directory embedded in the client-supplied id.

* test(session-search): follow the service search signature

Host-resolved paths are a second argument now, so the call-shape assertions
that pinned a one-argument call name it.

* fix(session-search): answer consent and readiness before an unknown scope

The registry short-circuited an unresolvable scope before current.search ran,
and current.search is where disabled and not-ready are decided. A host with
indexing off that lacks the project told the user it did not have the
workspace, which they cannot act on. The verdict now travels to the service
beside the request, and the service answers it after its own checks.

* fix(session-search): acknowledge only a scope that resolved

An unknown verdict is still a verdict, and it was being acknowledged as if the
host had narrowed. The skipped banner also counted only 'searched' as having
resolved the scope, so a host that resolved it and came back stale or timed out
let the scope lines reappear where they explain nothing.

* refactor(session-search): drop the version-mismatch receipt

No stable release ships search, so the only hosts that have it and predate
`within` are dev and ad hoc builds. The acknowledgement, the needs-update
outcome and the copy behind it would be permanent dead weight from the first
stable release on. The scope-unknown outcome and the off / not-ready / unknown
ordering stay.

Also trims this PR's new docblocks to the repo's one-line why rule.
2026-09-18 16:46:31 -04:00
Brennan Benson 0b57ce0295 fix(attention): count both terminal and chat siblings when clearing workspace unread (#21274)
* fix(attention): count both terminal and chat siblings when clearing workspace unread

A workspace holding a terminal pane and a structured chat tab built its
"anything still unread here?" inventory from the terminal tab list alone, so
acknowledging the visible terminal cleared the workspace's unread flag while
the chat's completion marker was still outstanding. The chat's unread was lost
with nothing left to relight it.

Structured chats now have their own attention-surface adapter, addressed by the
pane key the status producer already publishes — `<unifiedTabId>:<sessionLeaf>`
— with the unified tab id as its container id. Acknowledgement unions both
surface kinds' remainders, so either kind's hidden sibling holds the workspace
lit.

* fix(attention): rescan when focusing a split group
2026-09-18 12:37:28 -04:00
Brennan Benson a85e580e51 fix(orchestration): stop the sender-terminal refusal recommending another pane's handle (#21097)
* fix(orchestration): stop the sender-terminal refusal recommending another pane's handle

The structured-session guard told callers to pass `--from <terminal-handle>`, but the
explicit-flag branch returns before that guard runs — so following the advice succeeds,
against a handle that necessarily belongs to a different pane, and the next `check`
consumes that pane's unread mail.

Both refusals now say what is actually true: no handle names a structured chat session,
and a caller that does have one should pass its own.

Also pins ORCA_STRUCTURED_SESSION in the gate CLI test, which until now decided which
refusal it exercised from ambient environment.

* fix(orchestration): route the lifecycle-send refusal to the structured message

`orchestration send --type worker_done|heartbeat` refuses in the send handler before
`resolveOrchestrationTerminalHandle` runs, so the structured guard never saw the case a
structured session hits most: the canonical worker lifecycle report. That caller was still
told to pass `--from` with "your own terminal's handle" — which it does not have, so any
handle it picked would belong to another pane.

`throwNoActiveSenderTerminal` now derives which refusal fits instead of each call site
deciding: marker set AND no handle means no identity exists, so the structured refusal
applies. A stale `ORCA_TERMINAL_HANDLE` is deliberately excluded — that caller does have an
identity, it just went stale, and keeps the advice to re-run under a live one.

Also corrects the guidance itself (`--agent` is a `worktree create` flag; `terminal create`
has no such flag), aligns the SSH fallback wording with its local twin, and pins
ORCA_STRUCTURED_SESSION in the send tests, which until now decided which refusal they
exercised from ambient environment.
2026-09-18 08:15:12 -07:00
Brennan Benson 66e0847398 fix(agent-status): stop an auto-reviewed Codex approval reading as "Needs You" (#21389)
* fix(agent-status): stop an auto-reviewed Codex approval reading as "Needs You"

Codex runs its PermissionRequest hook as decider #1, ahead of both its own
review agent and the user, so the event means "a decision is being made", not
"a human is blocked". Under the "Approve for me" posture the review agent
resolves it seconds later, so every gated tool call drove the pane from Working
to Needs You and back, plus a desktop notification each time.

The execution host now reads the turn's approvals_reviewer off the rollout it
already tails for subagent reconciliation, and keeps a reviewer-owned approval
as working. Positive evidence only: an absent field, an older rollout, or an
unreadable file all still raise the wait, so this can never hide a real prompt.

Splits the incremental rollout JSONL cursor out of the subagent transcript
module, which the new reader pushed over the file-length cap.

* fix(agent-status): avoid stale Codex approval ownership

* fix(agent-status): reconcile Codex child approval ownership

* perf(agent-status): avoid reads for Codex child activity

* fix(agent-status): scope Codex reviewer ownership by transcript
2026-09-18 11:04:05 -04:00
Neil 01beadbcf0 fix(explorer): make filename search find all workspace files (#21423)
* fix(explorer): search file names through runtime

* fix(explorer): keep filename search results complete

* fix(explorer): narrow runtime search change

* test(explorer): remove unsupported local search assertion

* fix(explorer): fence filename search results
2026-09-18 04:05:23 -07:00
Neil 8f9a55ef8a fix(editor): restore editability after View Log (#21424) 2026-09-18 03:36:15 -07:00
593141590e fix(terminal): retire captured remote handles when pending panes close (#21005)
* fix(terminal): retire captured remote handles when pending panes close

A restored pane can hold a scoped `remote:<environment>@@<handle>` layout
binding while `remote.attach()` is still waiting for `terminal.resolvePane`.
The transport's `getPtyId()` is null, so an explicit split close passed null to
`closeWebRuntimeTerminal`, dropped the binding and destroyed only the viewer.
The host terminal stayed connected.

Only an exact scoped handle whose environment matches the owning workspace's
runtime authorizes the close. The provider helper captures the pairing
revision, runs its existing compatibility check, then rechecks pairing and
ownership immediately before dispatch.

Rebased onto main after #21001 was squash-merged. The previous head was a merge
commit that carried its own conflict-resolution content -- the runtime branch in
`terminal-pane-close-admission.ts` and the restored `it.each([false, true])`
parameter -- which a plain rebase drops along with the merge. Rebuilt from the
recorded net diff instead and verified byte-identical at 15 files,
906 insertions, 41 deletions.

* test(memory): rebase the pending runtime-close proof onto the squashed base

`fix.patch` recorded a baseline taken against #21001's pre-squash branch tip.
Squash-merging #21001 replaced that tip with a single commit, so the recorded
hunks no longer reverse-applied and `reproduce.mjs` aborted with
`Source changed: use-terminal-pane-close-actions.ts` -- confirmed by running it
before regenerating rather than assuming the rebase alone would fix it.

Regenerated against `main` and re-run: 5 pass / 10 fail before, 15 pass / 0
fail after, exit 0, and every `results.json` hash recomputed from the run
rather than hand-edited.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 02:13:11 -07:00
Neil 46d7ecf4d1 test(runtime): pin the merged-predecessor lockout the receipt ledger allowed (#20725)
The production change this branch carried - asking the receipt ledger the
same lineage-aware "is it retired" question as the recovery gate - landed
in the base branch (#19860) as part of "give 'same publisher' one answer
across the epoch fences". Rebasing onto that base leaves the regression
case, which is the part the base does not have.

`fences a merged predecessor at the recovery gate as well` stops one frame
early: it asserts the merged frame loses and never asks whether the live
successor still gets in afterwards. This case asks, for both the bare and
the merged shape. The bare shape passes without the ledger fix and is the
control.

Mutation: restoring `history?.retired.includes(publicationEpoch)` in
`recordReceivedWebSessionTabsSnapshot` fails only the merged-shape case.
2026-09-18 02:13:03 -07:00
Neil 5c2d3322c1 fix(runtime): name a terminal whose pane a graph republish dropped (#19860)
* fix(runtime): name a terminal whose pane the graph dropped

`buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement
with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose
`paneKey` still parses to its own `tabId` passes that forever, including long
after the session graph dropped the pane, so a terminal that had lost its
surface reported `orphaned: false, connected: true, writable: true` and a
`tabId` no tab has: field-for-field identical to a healthy one (#18191).

Consult the leaf topology instead, gated on a graph statement having had the
standing to contradict the record. `graphSequence` counts authoritative graph
statements; every statement re-records the surface of every pane it publishes,
so a pane the current graph holds carries the current stamp and is answered
without touching the leaf map. That covers the two absences that are not
evidence, without a second flag: a surface recorded since the last statement
(spawn records the pane before the graph carrying it arrives, #7587), and a
lost graph clearing every leaf at once without advancing the sequence. A pane
already observed dropped keeps its stale stamp and stays named, because losing
the ability to re-check is not a reason to un-see it.

`orphaned: true` is shipped vocabulary that both consumers already read, so no
capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and
now reaches this population, and the duplicate-surface index
(`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's
live owner.

* fix(runtime): publish a terminal retirement proof on the exit's own evidence

A paired client may drop a mirrored terminal on exactly two kinds of host
evidence: a `retiredTerminalSurfaces` proof naming the handle, or two
authoritative `terminal.list` inventories that omit it. The second needs two
host publications, and a quiet workspace publishes one, so the proof is the
only evidence that rides the frame carrying the retraction.

That proof was minted only as a byproduct of persistence *accepting a change*,
which made one value carry two meanings: "a change was accepted" and "the PTY
exited". The host renderer's close transaction de-persists the surface and
republishes without it, so when it got there first the exit found nothing left
to accept and the attestation died with it. Measured on a real paired client:
the host retracted in under 500ms, published no proof, then froze its
snapshotVersion for 60s while the client kept a dead pane in its tab bar.

Persistence still gates *removal* — publishing absence before the membership
fence is durable would let a crash resurrect the surface. It no longer gates
the proof: the observed exit is itself the attestation.

The exit-first ordering already had a passing test; the renderer-first ordering
had none, and that is the one users hit. Both orderings are now pinned, with
exit-first as the control that makes the renderer-first failures mean something.

Wire: `retiredTerminalSurfaces` is an existing optional field on an existing
path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is
Rule 1 — an old client that ignores it degrades to the two-inventory route it
already uses today, so no capability gate is needed. The sentence "the host
starts sending a frame it did not send before" reads like Rule 3; it is not,
because the frame shape, the field, and the reader contract are all unchanged.

* test(runtime): pin the removal frame retiring a still-live publisher

KNOWN RED (`it.fails`), no product change. Found while verifying the close
retraction fix: once the emptying actually reaches paired clients — a state the
previous behaviour never allowed, because nothing propagated — re-adoption of a
later create is flaky. Measured 1 failure in 6 runs of the two-client journey.

`decideWebSessionTabsSnapshot` treats the host's synthetic `removed:<t>`
retraction as a publisher handover: it retires the still-live renderer epoch and
installs the retraction as current, while the removal also clears the live
freshness record. The next frame from that same running publisher then matches
no lineage and reads as a retired generation, so it is outranked and the
publisher is locked out of the worktree until its generation changes.
`local-structured-session-tabs-sync/snapshot-apply.ts` documents this exact
scenario and has a revive escape; the mirror path has none.

The suffix case explains the 1-in-6: `hasRetiredValue` is an exact string match,
so a republication carrying `:headless-merge:` walks past the fence and only a
bare same-epoch republication is locked out.

Not fixed here on purpose. Dropping the retirement makes the red case pass but
breaks `web-session-tabs-sync.test.ts > keeps a removed worktree fenced against
delayed predecessor epochs`, which asserts a same-epoch higher-version frame
after a removal must be rejected. At this layer those are the same frame — this
function holds no `receivedFrame`, so it cannot separate a delayed predecessor
from the live publisher speaking again. The fix belongs in
`shouldApplyRecoveredWebSessionTabsSnapshot`, which does hold that ordering and
currently defers to the same epoch fence. That is a contract change across two
functions and an existing invariant, not a one-liner.

* fix(runtime): a removal retraction is not a publisher handover

The host drops a worktree's entry when its last tab closes and announces it
with a synthetic `removed:<t>` epoch. Both receipt sites treated that as a
publication: `decideWebSessionTabsSnapshot` and
`recordReceivedWebSessionTabsSnapshot` each noted the retraction epoch as
current, which pushed the still-live renderer epoch onto `retired`. The removal
also drops the live freshness record, so the next frame from that same running
publisher matched no lineage, read as a retired generation, and was outranked.
The live publisher was locked out of its own worktree until its generation
changed. That is fail-closed, and it is why re-adoption after an emptying was
flaky once the emptying actually reached paired clients.

A retraction and the live publisher's next frame are the same epoch at a higher
version, so epoch identity cannot separate them and never could. Delivery order
can. `recordReceivedWebSessionTabsRemoval` now records the retraction as the
worktree's newest received evidence instead of deleting the ledger, so
`shouldApplyRecoveredWebSessionTabsSnapshot` — the gate every production apply
path passes before `decideWebSessionTabsSnapshot` — fences a frame that
reserved its received frame before the retraction while admitting one that
arrives after it. The boundary carries the retraction's own epoch, which never
matches a host publication, so a later live frame may still restart its version
counter. `local-structured-session-tabs-sync/snapshot-apply.ts` documents the
same conclusion for the local path: a retired epoch is not proof of a dead
generation.

`keeps a removed worktree fenced against delayed predecessor epochs` pinned the
delayed predecessor at the raw decision layer, which is the same call as the
live publisher's republication. It now pins the identical scenario — same
epoch, higher version, still rejected — through the receive-and-apply path that
actually holds the ordering, plus the composed gate as production spells it.

The committed `it.fails` repro is not sufficient on its own: it records no
received frame, so dropping only the `decideWebSessionTabsSnapshot` retirement
turns it green while the publisher stays locked out on every real path. A
receive-and-apply case is added alongside it to close that gap.

* test(runtime): pin the retraction boundary against a stale inventory omission

Mutation testing left a survivor: writing the boundary unconditionally, instead
of only when it advances the ledger, passed the whole runtime suite. It is not
inert. A visibility-resume inventory reserves its received frame before it
lists, so an omission it reports can be older than a stream frame that landed
meanwhile; without the guard that stale omission rewinds the ledger, forgetting
the stream frame's version, and a delayed list reserved in between is then
readmitted instead of outranked. This pins that ordering.

The one remaining survivor is the boundary's `snapshotVersion`, and it is inert:
the ledger's version is read at exactly two sites, both reachable only when the
incoming frame's epoch equals the stored one, and a retraction epoch never
equals a live publication.

* test(runtime): cover the fences the retraction change narrowed

Two gaps found by mutating the fences themselves rather than the fix.

Deleting the epoch fence in `shouldApplyRecoveredWebSessionTabsSnapshot` passed
the entire runtime suite. It is not unreachable: a superseded generation whose
sibling stream delivers its frame after the handover outranks the successor on
delivery order, and only the retired-epoch check rejects it. Retractions used to
exercise that fence too; now that they no longer retire anything, a genuine
handover is the only thing left that reaches it, and nothing covered that. The
fence is narrower than it was, not dead.

The second case pins rate-independence. The defect surfaced 1 run in 6 because
`hasRetiredValue` is an exact string match while `sameSessionTabsPublicationLineage`
treats `:headless-merge:` as the same publisher, so a merged republication walked
past a fence a bare one hit. The removal path is now asserted over both epoch
shapes through the full path, so a fix that only re-rated the defect instead of
removing it would fail here.

* fix(runtime): give "same publisher" one answer across the epoch fences

Separable from the retraction fix beneath it, and it changes handover-path
behaviour: a superseded generation that republishes under a merged epoch is now
rejected where it was previously accepted. Take it independently or not at all.

`publisher-identity-fences.ts` held two answers to "is this the same publisher".
`noteRetiredValue` treated a `:headless-merge:` epoch as a SUCCESSOR of its base
and retired the base when the merged form became current, while
`sameSessionTabsPublicationLineage` treated the two as ONE publisher. Those are
contradictory, and the retired-value check's exact-string match was the shim
that kept them from ever meeting: a merged frame was a different string, so it
never looked retired no matter what had been retired.

The cost was that the same predecessor was accepted or rejected depending on
which shape it arrived in. A generation a successor had replaced was fenced when
it republished bare and admitted when it republished merged — the fail-open half
of the same disagreement whose fail-closed half was the removal defect, and the
reason that defect reproduced 1 run in 6 rather than every time.

This cannot be fixed in the fence alone. Making the fence lineage-aware while a
merged epoch still retires its base has the generation retire itself: the
rebuild arrives, retires its own base, and the fence then rejects it as a
retired generation. So both sides move together — a lineage sibling advances the
current epoch instead of superseding it, and inherits its generation's
retirement instead of escaping it.

Scoped to the publication-epoch functions. Runtime-id retirement keeps exact
matching, and `local-structured-session-tabs-sync` keeps its own
`hasRetiredValue` call, where a lineage sibling is already excused explicitly
and a retired epoch is deliberately not treated as proof of a dead generation.

* test(e2e): journeys for a reopened client and two clients on one host

Two gaps this suite had no coverage for, both driven end to end against a real
paired desktop client rather than at a seam.

A relaunched client holding a live remote terminal: every paired restart spec
here restarts around a browser pane, none around the terminal the user is
actually mid-work in. The host-side fixture's on-disk sink is the oracle — one
READY for the whole run proves the host never re-spawned the session, and a
recorded line for input sent after the relaunch proves the restored pane is
wired to that same process rather than painted with its scrollback.

Two clients on one host across an emptied workspace: the tombstone is
client-local on the runtime path, so a client that never held a row still seeds
into a workspace another client deliberately emptied. That asymmetry is by
design; a client falling out of step with the host and staying there is not.
Phase 0 is the control — without it a later divergence cannot be attributed to
the emptying rather than to mirroring never having worked.

The input probe goes through `pane.terminal.input`, not `window.api.pty.write`:
a mirrored pane's handle is a `remote:` id that no local PTY answers to, so a
direct write is swallowed and the assertion passes on nothing. The pre-restart
control exists to catch exactly that, and did.

* test(e2e): keep the two-client journey spec type-clean

* test(e2e): pin the close retraction a paired host does not publish

* docs(e2e): say why the red close-retraction spec sits on this PR

The spec was written on a branch carrying neither of this PR's publish-side
fixes, and its own diagnosis -- the fault is the host's publish-after-close,
not any client's mirror -- names exactly what they change. Landing it here
makes CI the measurement rather than leaving a red spec parked on a branch
with no fix in it.

Records the one thing a reader needs to not do: skip-tagging it. And why the
obvious split is not a block move -- phase 2 depends on phase 1b's emptying
and both share the two-client pairing fixture, so splitting means duplicating
the fixture.

* test(e2e): the close-retraction spec is green on this branch, measured

It was written to pin a defect and was red where it was written. On this
branch, with `publish a terminal retirement proof on the exit's own evidence`
and `a removal retraction is not a publisher handover` both present, it passes
-- twice, independently: phase1a A=9ms/B=158ms then A=2ms/B=1ms, against a
prior baseline of "none reached either client within 90 seconds".

So the KNOWN RED header had become the thing it warned about: a test carrying
prose asserting the very behaviour the commits beside it remove. Rewritten to
record the measurement and the numbers to regress against, and to keep the one
instruction that still applies -- if it reddens again, do not skip-tag it; the
failure shape is a 90s timeout on both clients at once while creates still
propagate.

No assertion changed. Comment only.

* test(wire): pair the session-tabs retirement proof across two builds

The stack makes a host start sending a retirement proof on its own frame
when no surface removal carries one. The change argues Rule 1; Rule 3's
fourth bullet covers a frame the host starts sending on an existing path,
so the claim is measured against v1.4.199 rather than accepted.

Neither existing cross-version suite reaches session-tabs: the terminal
one covers the binary stream, the agent-session one covers agentSession.*.

Result: the old client acts on the proof-only frame, because the whole
client half of this surface is unchanged. The old-host cells are pinned
to a release that cannot publish the frame at all, which is what makes
the new-host cells mean something.

* fix(lint): clear the casting gate on the surface-lost inventory

main tightened typescript/consistent-type-assertions to assertionStyle:
never, which the rebase brings onto these added lines. The retraction
read narrows on the property instead of casting; the fixture and
cross-build-import casts carry per-site SAFETY rationales.

* fix(lint): bind the protected-stamp cast to a name

The leading-semicolon parenthesised call put the suppression on a line
oxfmt then reflowed away from the assertion it covers. Naming the
narrowed handle keeps the directive next to the cast.

* fix(runtime): route every non-null surface write through the stamped writer

`ptyHoldsRecordedSurface` trusts a record only while its stamp is current;
after that the leaf map answers. Four writers still named a pane with a bare
`tabId = / paneKey =` — orphan adoption (both branches), split, create on an
adopted stable pane, and TUI-owner recovery — so a record that had already
been contradicted stayed contradicted after the claim, and `terminal list`
reported the just-claimed PTY `orphaned: true` until the renderer's next
graph statement re-recorded it. Before this branch those sites read as
attached at once, so this was a regression window of one round-trip, and
`indexLiveTerminalSurfaceOwners` reads `orphaned` as "unowned".

`recordPtySurface` is now the one writer; the adoption module reaches it
through a port because it has no `graphSequence` of its own. The nulling
writers are untouched: a null surface is never held, stamped or not.

* test(runtime): keep one copy of each publisher-fence case

The removed-frame suite asserted four properties that another case in the
same suite or the lineage suite already pinned:

- the decide-only readmit and the bare full-path readmit are the bare arm of
  the parameterized full-path readmit, verbatim;
- the merged-suffix decide-only readmit is the merged arm of the same loop;
- "still fences a predecessor a successor replaced" is the lineage suite's
  bare arm with different version numbers;
- the recovery-gate handover case is the lineage suite's recovery-gate case
  with a bare late frame instead of a merged one, so that test now runs both
  shapes and this copy goes.

Mutation-checked: reverting each of the five renderer changes on this branch
(retire-on-removal in decide, noting a retraction current, the exact-match
retired fence, merge-supersedes-base, dropping the ledger on removal) still
fails at least one of the remaining ten cases.

Also corrects the suite header: a retraction carries a synthetic `removed:`
epoch, so it is the in-flight predecessor frame, not the retraction, that
shares the live publisher's epoch and needs delivery order to be separated.

* test(e2e): fail the two-client journey when phase 1a cannot run

Phase 1a sat inside `if (beforePartialClose.length > 1)`. A host workspace
that starts with one terminal skipped the control silently while 1b and 2
still ran, and the spec passed green without ever exercising the
close-with-others-open retraction it was written to measure. The skip is now
a recorded failure naming the host count.

* fix(runtime): order every session-tabs apply path against the retraction

A closed terminal came back on the other client because "this worktree was
retracted" was neither durable nor universal:

- `refreshWebRuntimeSessionTabsSnapshot` reached `decide` with no place in
  receipt order at all, so a list the host answered before the close applied
  after the retraction had already cleared the worktree. It is a production
  path for close, create, activation, split and PTY reconnect.
- the boundary lived in a single receipt slot the next stream frame overwrote,
  and in a fence that only existed when a recovery happened to be pending when
  the retraction landed, so a pre-close list could out-rank the republication
  on `snapshotVersion` alone.

Replace both with one raise-only removal watermark per (environment, worktree)
and give the list path a receipt position, reserved by the request and carried
in its answer so a dedupe joiner inherits it rather than minting a newer one.
The pending-recovery fence and its bookkeeping are dead once the boundary is
monotonic. The exact-match retirement check in the receipt ledger becomes the
one lineage-aware predicate, so a `:headless-merge:` rebuild can no longer be
noted as current and retire the live publisher out of its own worktree.

On the main side, `recordPtyWorktree` stamped `surfaceRecordedAtGraphSequence`
at write time, so any `paneKey` write claimed the standing of a fresh graph
statement. The inventory restore in `terminal list` therefore un-dropped the
very pane the read was meant to report, on every listing. A surface claim now
carries no graph standing unless its writer names one: the graph statement,
live leaf output and spawn do, while the inventory restore, the floating
liveness restore and the mobile projection replay do not. Defaulting this way
means a writer that says nothing fails safe and self-corrects, which the type
alone could not guarantee across the projection contract's own `recordPty`.

Spawn claims now span the one graph statement the renderer may already have in
flight, and retirement proofs compare by identity instead of by position, so a
re-delivered exit no longer fans out a `snapshotVersion` bump carrying nothing.

* fix(runtime): stop an unpublished-worktree placeholder retiring the live publisher

A worktree the host has published nothing for still answers a forced list, with
a synthesized `none`/v0 frame that means "ask me later"
(host-session-snapshot-authority.ts). Every post-close list and every
activation of an emptied worktree gets one. Noting it as a publication retired
the renderer generation that is still live, and because that epoch is
per-process, the terminal the user created next never reached this client — the
same lockout the retraction path was already careful to avoid, through a door
it did not cover. `local-structured-session-tabs-sync` already skips the
placeholder for this exact reason; the web mirror now does too, on both the
receipt ledger and the frame decision.

Bound the receipt ledgers by frame age rather than entry count. One bootstrap
inventory records a receipt per worktree under a single reserved frame, so
evicting by insertion order dropped that batch's own earlier entries, and an
absent receipt is what the recovery gate reads as "no evidence for this
worktree". Only a receipt no in-flight frame can still be ranked against is
droppable.

Take the receipt gate off the `web-session-tabs-sync` barrel in the refresh
path. Ordering is that path's gate, not an optional collaborator a caller's
module mock may leave out, and being reachable only through the barrel is how
the path came to have no ordering at all.

* fix(runtime): let the TUI-owner recovery name its pane without claiming the graph holds it

`recoverStructuredTuiOwner` rebinds a recovered PTY from the persisted owner
binding — the same replayed-evidence class as the inventory restore — but
stamped it with the current graph sequence, so a pane the renderer had already
dropped read as attached for one more statement. The guard below it needs the
tabId and paneKey, not the standing.

Also say plainly in `decideWebSessionTabsSnapshot` what the affirms check does
and does not cover: an unpublished-worktree placeholder is withheld from epoch
noting only. It still applies, because rejecting it outright would drop the
terminal reconciliation that legitimately rides on it.

* fix(runtime): keep the retraction boundary out of the receipt bound

Bounding the removal watermark alongside the receipt ledger reintroduced the
defect the watermark exists to prevent: past 512 retracted worktrees, evicting
a boundary readmits every pre-close frame it was fencing, and a delayed list
resurrects the closed tab. A boundary is not a cache. One number per worktree
ever retracted on an environment is the cheaper price, and environment teardown
drains it; only the receipt ledger stays bounded, by frame age.

Split the orphan-adoption port by provenance so the last writer that disagreed
with the surface-standing rule stops disagreeing. `adoptRuntimeTerminalOrphans`
replays the persisted binding when the claim already matches it and writes a
new one otherwise, and both went through a single `recordSurface` that stamped
the current graph sequence — so re-adopting an already-adopted orphan lifted a
dropped pane's stale stamp and reported it attached, in a quiet workspace
possibly forever. The replay now names the pane without standing and the fresh
claim takes spawn standing, like every other writer.

Replace a receipt-count assertion that was vacuous for a map keyed by
environment and worktree with the mirror state and freshness it was standing in
for.

* fix(runtime): keep a closed-tab worktree under the epoch already publishing it

`closeHeadlessMobileTerminalTab` minted `headless:<now>` on every close. Its
sibling headless writers carry the stored `publicationEpoch` forward and mint
only when there is no snapshot to inherit from — because a write to a worktree
is not a claim to publish it. The close was the one writer that claimed.

A paired client retires the epoch a new publisher displaces, and the web
mirror's retirement is final: there is no revive lane, and the per-worktree
tracking teardown deliberately keeps the epoch history. So an ordinary close
published a stranger for a worktree the renderer generation still owned, retired
that generation on every client, and the renderer's next publication — carrying
the epoch the close had just retired — was rejected forever. The user emptied a
workspace, created a terminal, and it never arrived on either machine while
`session.tabs.list` showed the host holding it.

This is the same thesis the retraction path already states, through the door
next to it: a retraction is not a handover, and neither is a close.

Measured on `paired-two-client-emptied-workspace-reseed.spec.ts`, six runs each:
phase 2 failed 3/6 before (`A=null B=null`, both clients blind for the full 30s
budget) and 0/6 after, with both clients adopting in single-digit milliseconds.

* fix(lint): give the fixtures real types instead of casting past them

The casting gate failed on eight assertions this branch added. All eight were
suppressible, but the suppressions were not the problem: the casts were hiding
fixtures that did not match the contracts they stood in for.

`sessionStillHoldingBothPanes` built tabs as `{id, title, type}` — `type` is not
a `TerminalTab` field and eight required ones were missing — and layouts holding
only `ptyIdsByLeafId`. `as never` made both compile. They are now real
`TerminalTab` / `TerminalLayoutSnapshot` values, so the fixture is checked
against the type `listTerminals` actually reads.

`terminalTab` in the epoch suite built a *client* tab (`status`, `terminal`) for
a field typed with *snapshot* tabs, which forced `as never` at the call and a
cast on the snapshot itself. Production reads only `type`, `parentTabId`,
`leafId`, `ptyId` and `parentLayout` from that tab, so the two client-only
fields were inert; dropping them lets the declared
`RuntimeMobileSessionTerminalTab` type the fixture end to end, and the closed tab
is now held by name rather than recovered from `snapshot.tabs[0]`.

The remaining three casts are unchanged in kind and now carry correctly placed
SAFETY rationales: reaching a protected member is the only way to drive these
paths. `graphSequence` folds into the reach-through that was already there
rather than opening a second one, and the map read narrows instead of asserting.

Mutation-tested, all three suites, regression re-introduced for each:
- epoch mint on close restored -> 1 failed | 1 passed
- orphan check reverted to self-consistency -> 4 failed | 4 passed
- placeholder retirement guard removed -> 1 failed | 7 passed

src/main/runtime 8169 passed | 31 skipped; src/renderer/src/runtime 1581 passed.
`check:code-quality:changed` goes 8 findings -> 0. `pnpm tc` clean.

* fix(runtime): stop the headless placeholder graph from dropping every restored pane

A headless server publishes one empty graph at launch so status clients see a
ready server. It names no renderer pane and is never replaced, but it was
counted as an authoritative graph statement all the same: `graphSequence` went
0 -> 1 while the leaf map stayed empty for the life of the process.

Every surface claim written without standing - a persisted replay, an inventory
restore, the TUI-owner recovery - is stamped 0. Against `graphSequence` 1 the
`>=` guard fails, the empty leaf map answers "no pane holds this", and the
terminal reports `orphaned: true` under a `pty:` tabId. Nothing can re-stamp it,
because the only graph that host will ever publish has already been published.
On a headless or SSH host that is permanent, and it is the same lie #18191 is
about, pointed the other way.

The placeholder no longer spends a graph statement. A renderer graph still does,
so a pane a real graph drops is still reported dropped - including on a desktop
window promoted from headless, which the third case pins as a negative control.

Mutation: restoring the unconditional bump fails the first two cases
("expected 1 to be +0", "expected true to be false"); the promoted-window
control passes either way, as a control should.

Also registers tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
in the cross-version-wire job. The file matches CROSS_VERSION_WIRE_PREFIXES, so
adding it had switched the job's gate on, but the job runs an explicit file list
that omitted it - the test executed nowhere in CI. It passes 8/8.
2026-09-18 01:56:07 -07:00
Neil 9d1826ae65 fix(session): repoint the rows a worktree re-key strands (latent; producer is flag-disabled) (#20057)
* fix(session): keep a renamed worktree's rows from matching on the id it lost

Three persisted session fields survived a worktree re-key still naming the old
identity. Two of them are suppression records, so a stale id does not read as
residue -- it silently re-admits state the user removed:

- closedTerminalTabTombstonesByTabId: the remote merge only suppresses a host
  tab when the tombstone's worktree equals the tab's, and no snapshot ever
  covers the old id, so the tombstone never retires either.
- clientHostedBrowserCloseIntentsByEnvironment: the replay targets the intent's
  worktree, and an unresolvable selector answers selector_not_found -- which the
  replay reads as definitively gone and uses to DROP the intent.
- clientHostedBrowserPagesByWorktree: keyed by worktree and re-checked against
  the row's own workspaceId, so both halves have to move or the pages are never
  rehydrated.

Fixed on both sides of the rename: the main-process persisted migration and the
renderer's live store, which would otherwise write the stale values straight
back. The coverage test drives off WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND,
the census these three fell out of, with the shipping owner collector as its
oracle.

* docs(session): record why a re-key clobbering an existing target stays unfixed

Not a missing guard -- an unresolvable one. Keeping the target is correct when
it holds a real closed-last-terminal tombstone; keeping the source is correct
when the target row is a stub; nothing records which is newer. The recency map
is the only one that can settle it, because Math.max needs no such ordering.

* test(persistence): measure the downgrade direction for worktree identity

The stack widens migrateWorktreeIdentity to repoint worktreeId inside
session rows. That changes what lands on disk with no wire change, which
is Rule 3's shape applied to persistence, so it is measured against
v1.4.199 rather than reasoned about.

Result: new-build state does not break the old build. The old build
renames over it without throwing and loses no row; the two row kinds it
cannot repoint stay stale, which is exactly what its own renames already
produce.

The numbers are measured. A first draft asserted the old build repointed
no inner rows at all; it repoints two of four, and the probe is what
caught that.

* test(ci): run the worktree-identity downgrade lane instead of describing it

The cross-version job names its files explicitly, so a new one is inert until
it is listed; the sharded unit job excludes the whole directory and the E2E
router only takes `*.spec.ts`. Also pairs the forward-compat case against the
current build — the stack's own field-list walk is the guarantee that matters,
and only the frozen build was exercised.

* refactor(session): drop the type assertions the rename migration leaned on

`consistent-type-assertions` landed on main after this branch last built, and
three of the `as never` fixtures were hiding real contract drift: a browser
workspace row missing six required fields, a tab group naming three fields the
type does not have while omitting the two it requires, and a sleeping-agent row
whose `providerSession` had neither `key` nor `id` and whose `state` was not in
`AgentStatusState`.

Indexing the session by a computed field name is what forced the casts in the
migration, so the four row maps are now spelled out; the census test is what
keeps a fifth from joining silently. The renderer test builds its state from
the real slice instead of casting a four-field partial.

* refactor(test): name the module namespace the skew harness reads

`object` is too broad for the anti-slop gate, and the import helper already
declares what it hands back.

* docs(test): say which maps the harness actually supplies

The two under test live in slices this harness does not mount, so calling it
"the real slice's state" overclaimed.
2026-09-18 01:11:03 -07:00
d139760c06 fix(sessions): cancel transcript acquisition during host teardown (#21006)
* fix(sessions): cancel TUI transcript acquisition during teardown

* fix(sessions): settle canceled handoffs without replacement launches

* test(sessions): assert fenced teardown release

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-18 01:10:32 -07:00
Luke Son 01a33bc427 fix(git): respect existing .orca ignore rules
Respects effective local, WSL, linked-worktree, runtime, and SSH Git ignore rules before updating .gitignore. Fixes #21212.
2026-09-18 00:53:09 -07:00
1ff4fe677c fix(main,preload): tear down renderer relay and preload listeners (#20909)
* Clean up renderer relay listeners on teardown

* fix(main): guard empty markdown relay results

* test: document relay window test double safety

* fix(relay): retain web contents through window destruction

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-18 00:18:19 -07:00
Jinwoo Hong f819ed96ca fix(skills): keep the disposal verdict when staging cleanup fails, and retry release-cut installs (#21366)
* fix(skills): keep the disposal verdict when staging cleanup fails

`begin()` ended with `await this.removeOwnershipIfDisposed()` inside its `finally`,
so when a caller raced `dispose()` the rejection it received was whatever that
opportunistic `rmdir` threw -- not `skill-upload-service-disposed`. A caller could
not tell "the service shut down" from "the filesystem broke", and the Windows
release gate saw it as `EPERM: operation not permitted, rmdir`.

Two causes, both fixed here:

- The EPERM itself: an in-flight operation and disposal each call
  `ownership.remove()`, so two `rm -rf` run concurrently against the same owner
  directory. On POSIX the loser reads ENOENT and `force: true` swallows it; on
  Windows the loser reads a delete-pending directory and gets EPERM.
  `SkillUploadStagingOwnership.remove()` now joins one removal and forgets it on
  failure so a later caller still retries.
- The masking: cleanup in a `finally` no longer replaces the outcome of the call
  it is cleaning up after. Disposal retries staging removal and reports its own
  failure, matching `removeUnpublished`/`retainFailedCleanup` in this class.

Both regressions are pinned platform-independently: one injects a failing
ownership removal and asserts the racing `begin` still rejects with
`skill-upload-service-disposed` while `dispose()` reports the cleanup failure; the
other models Windows delete-pending rmdir in the `node:fs/promises` mock, which
turns a second removal into EPERM on every platform.

* ci(release-cut): retry the installs that fetch node-gyp headers

`golden e2e windows` installs with lifecycle scripts enabled, so pnpm runs
node-gyp for the `native/windows-registry` workspace project, which downloads that
Node version's headers from nodejs.org. A single `read ECONNRESET` on that fetch
failed a blocking release gate, and the release build job one screen below already
wraps its install in `nick-fields/retry@v4` for exactly this class of failure.

Both remaining unretried installs in this workflow (the blocking platform golden and
the non-blocking rendering-evidence lane) now use the same wrapper, and a contract
test keeps every release-cut install retryable.
2026-09-18 03:12:46 -04:00
1e3795de99 fix(log-tail): retire watches with their renderer lifetime (#21009)
* fix(log-tail): retire watches with their renderer lifetime

* fix(ci): clean up renderer tests

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-18 00:02:03 -07:00
2bdf281433 fix: avoid retaining foreign SSH file frames before metadata (#21167)
* fix: avoid retaining foreign SSH file frames before metadata

* test(ssh): exercise empty metadata through the streaming mux fixture

* fix(ssh): fail the file read when beforeResolve never runs

Moving the metadata install from .then() to beforeResolve moved it from a
mandatory callback to an optional one, and handleResponse clears the request
timer before beforeResolve runs. That left "response fulfilled, metadata never
installed" with no deadline: the read never settled, holding its notification
and dispose closures until mux disposal. Before this PR the same state failed
after the 60s inactivity deadline.

Unreachable with the concrete mux, which calls resolve on the line after
beforeResolve, but the hook is optional in the type and nothing enforces the
pairing. The guard is a no-op on every real path: empty, missing streamId,
cap-exceeded and alloc-failure all settle first, and the success path sets
metadataReady.

Found during review of #21167; raised at
https://github.com/stablyai/orca/pull/21167#issuecomment-5726058832

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-17 23:59:42 -07:00
Neil 06a8ca5f69 fix(runtime): keep a client-dirty mirrored file dirty across a host republish (#21393)
The host publishes only its own store's isDirty and never learns about
client edits, so rebuilding a mirrored OpenFile from the snapshot cleared
the client's flag while editorDrafts still held the draft. The tab strip
then closed the tab with no unsaved-changes prompt and closeFile deleted
the draft; the external-change reload guards would reload over it too.

Keep the client's flag when the client's file is dirty and it holds a
draft; with no draft the host's flag still wins so a host-side save does
not strand the tab as dirty. A host-side save never clears a client draft.

Fixes #21392
2026-09-17 23:56:11 -07:00
Neil 0d7381d1f2 fix(terminal): keep an unverifiable park-reveal snapshot apart from an empty pane (#21396)
On a park-reveal of a remote-runtime pty the host snapshot probe is the only
structural paint (the reattach carries no relay tail). Every non-snapshot
answer collapsed to null with no retry, so a host that stayed silent past the
request timeout, or answered 'no-serializable-buffer' ("not proof the pane is
empty"), painted the same blank pane as a host with nothing. That reads
unverifiable as exited (docs/reference/ssh-execution-boundary.md).

Classify the probe three ways: a host image paints; permanently-unavailable /
unavailable paints nothing and asks nothing; everything that proves nothing
(timeout, host declined for now, local lane gate, imageless success) paints
nothing and hands off to the hidden-output restore loop, which already budgets
retry-worthy answers (7 host declines / 30 local gates / 5 re-arm cycles),
repaints from the host on success, and ends in the explicit loss banner. The
reveal's own probe is charged to that same budget, so the bound is shared, not
doubled. No structural clear is issued on the unverifiable path, so whatever
the layout replay painted from the client's own copy stays visible.
2026-09-17 23:54:26 -07:00
69246e9b06 fix(terminal): retire explicitly closed pending split connections (#21001)
* fix(terminal): retire explicitly closed pending split connections

* test(memory): keep pending split proof compatible with formatted source

* fix(terminal): confirm pending split retirement before stopping work

* fix(terminal): restore the pending split-close gates CI checks

Three CI gates were red on this branch and all three were this branch's own.

The hook-order parity snapshot did not count the `confirmedCloseRef` this
branch adds to `use-terminal-pane-close-actions.ts`. Dumping the flattened
order against clean `main` shows exactly one added `useRef` at position 148
and no reordering, so the count moves 211 -> 212 and the digest with it.

`pending-split-close-test-fixture.ts` is Vitest support code, but it sits
outside the `*.test` / `*.spec` / `tests` globs that already switch
`anti-slop/no-module-mocking` off, so the gate failed on all twelve of its
`vi.mock` calls. It carries a file-scoped disable with the reason, matching
`work-item-search-test-harness.ts`.

`fix.patch` still described the pre-confirmation shape of the close hook, so
`reproduce.mjs` aborted with `Source changed` and the cited ablation could not
run at this head. Regenerated against the committed sources; the harness again
reports 10 pass / 14 fail before and 24 pass / 0 fail after.

Merges `main` rather than rebasing: #21005 is stacked on this branch.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-17 23:52:01 -07:00
Brennan Benson 9907117569 feat(native-chat): record an explicit provider outcome on every structured turn (#21278)
A structured turn that FAILED was recorded as `completed`, identically to one
that succeeded, so nothing downstream could tell them apart. Claude mapped only
its two abort reasons to `interrupted` and let an API error fall through to
`completed`; Codex collapsed every non-`completed` status to `interrupted` and
read a missing status as a clean finish.

Add `outcome` — success / failure / cancellation — to the turn record, emitted by
both providers. The four-arm lifecycle union is deliberately untouched: it stays
a report on what the HOST observed, and its readers are unaffected by
construction.

Absent means UNKNOWN and never success. Historical rows, older hosts, and any end
the host inferred rather than heard (the child going away, a turn superseded
before its result) all carry no outcome, so a newer client cannot mistake an old
host's `completed` API error for a clean turn.

Claude's abort-reason list had a second copy in the provider-fallback reader;
both now classify through one `claudeResultOutcome`, so the durable verdict and
the visible error row cannot drift.
2026-09-18 02:41:40 -04:00
f9d5b6bb02 fix(renderer): dispose global listeners during HMR (#20908)
* fix(renderer): dispose combined diff cache listener on HMR

* fix(renderer): dispose contextual tour key guard on HMR

* fix(renderer): dispose activity pagehide listener on HMR

* fix(renderer): dispose keyboard layout hooks on HMR

* fix(renderer): dispose input quiet listeners on HMR

* fix(renderer): dispose desync sentinel listener on HMR

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

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-17 23:40:00 -07:00
Neil 2cc34de756 fix(editor): keep an unresolvable mirrored file tab open with a truthful terminal state (#21375)
* fix(editor): keep an unresolvable mirrored file tab open with a truthful terminal state

A host-mirrored file whose read keeps answering `selector_not_found` used
to sit on the raw code forever (and, in the reverted #21363, was closed
outright, discarding drafts). `selector_not_found` is the host's "could
not resolve right now", not proof the workspace is gone, and the file-read
path has no definitive absence code.

Bound the retries as before, then swap in a truthful terminal message
with Retry and Close tab. The tab is never closed automatically; Close
routes through the unsaved-changes queue so a dirty draft is confirmed.

Fixes #21041

* fix(editor): classify selector_not_found by RPC code, not message text

Preserve `RuntimeRpcCallError.code` on `FileContent.loadErrorCode` and gate
the host-unresolved terminal transition with `hasRuntimeRpcErrorCode`, so a
host that sends `{ code: 'selector_not_found', message: 'Selector not found' }`
reaches the same truthful state as one that puts the bare token on the
message. Also drop the Close action on inline conflict-review rows, which
are not open tabs and would have been a dead control.

* fix(editor): localize the host-unresolved copy by sentinel, and pin the token matcher

Separate the terminal state's comparison key from its display text: the
retry hook stores `WORKTREE_HOST_UNRESOLVED_CODE` on `loadErrorCode`, and
the error view localizes by that code (`editor.fileLoad.hostUnresolved`),
so translating the message can never break the terminal check. Export the
selector_not_found matcher and cover near misses (case, suffix, prose,
wrong code) so only the defined token classifies.

* test(editor): name the it.each parameter for the host answer it labels

* fix(editor): drop the load-error Close action; closing stays with the tab strip

The Close button routed through `requestEditorFileClose`, which skips the
pinned-tab and shared-reference checks the tab strip applies, has no
listener outside the Terminal workbench (floating editor panels), and on
the conflict-review overview could target an unrelated open tab whose id
is the same absolute path as a synthesized inline row. Rather than
reimplement the tab strip's close semantics in a second place, the error
view keeps Retry and its copy points the user at closing the tab.

* fix(editor): reword the host-unresolved copy and namespace its sentinel

The copy no longer points at a Close control that is gone ("close this
tab from the tab strip") and no longer claims a scan is in progress, since
`selector_not_found` is also thrown synchronously for unregistered folder
workspaces and removed repos. The sentinel becomes
`editor_host_workspace_unresolved` so it cannot be confused with the CLI's
`worktree_host_unresolved` client error. The doc comment narrows the
"no definitive absence code" claim to git worktrees and names the two host
codes that are definitive but not yet classified.

Refs #21041
2026-09-17 23:23:17 -07:00