Commit Graph
6113 Commits
Author SHA1 Message Date
Brennan Benson a3604803cc perf(runtime): gate terminal.list visual layouts and stop the false writable claim
visualLayouts is ~31% of a large terminal.list payload (44,208 B of 137,412 B on a live 134-terminal remote runtime) and has exactly one consumer: the human-readable CLI formatter. Gate it behind an includeVisualLayouts request param that defaults to included, so pre-flag clients are unaffected, and have every --json/internal caller opt out.

Also drop the record-backed builder's writable, which was a verbatim copy of connected. terminal.show now states writability explicitly as exactly what terminal.send's PTY gate enforces.
2026-08-03 23:42:02 -07:00
401f66939d fix(codex): do not silently run the previous account when managed auth is unreadable (#12407)
* fix(codex): never launch the previously synced account during the credential grace window

The credential-absence grace window exists so a mid-rotation read of a managed
auth.json does not deselect the account over a race. In the shared runtime-home
lane it did that by returning early from syncForCurrentSelection, which leaves
the runtime home holding whatever it held before — the previously synced
account's credentials.

So when the selected account's auth.json was missing or unreadable (home on a
disconnected network drive, never provisioned, removed, or held under an
EPERM/EACCES lock by Windows AV), the next launch ran Codex as the *previous*
account while Settings showed the selected one: wrong quota, wrong identity,
work attributed to the wrong account, and no signal to the user.

Not being able to read the selected account's credentials proves nothing about
the identity already mirrored in the runtime home. The grace window now only
short-circuits when Orca can prove the runtime home already holds that same
account (shared-runtime-auth provenance, falling back to the in-memory sync
state on pre-provenance installs). Otherwise the runtime auth is cleared and
the provenance fenced: the selection survives in case the absence heals, but
the launch is logged out rather than logged in as someone else.

Tests cover ENOENT, EACCES/EPERM, and a genuinely still-settling rotation of
the selected account, which keeps its grace and its credentials.

* fix(codex): keep a mirrored token refresh when refusing an unproven selection

Clearing the shared runtime auth to avoid launching the wrong account can drop
bytes Codex refreshed in place — on a cold start whose mirror still holds the
previous account, no outgoing read-back has run yet, so those tokens exist
nowhere else and OAuth refresh-token rotation would force a re-login.

Read the mirror back to whichever managed account owns it (identity + freshness
proven, as on an account switch) before removing it.

* fix(codex): keep an unreadable managed home from dropping another account's mirrored refresh

An account whose auth.json cannot be read aborted the whole read-back scan,
so removing the runtime mirror for an unproven selection destroyed a token
refresh it held for a different account. Skip the unreadable home instead.

* fix(codex): prove mirror ownership from the credential, not just provenance

Review found the unproven-selection clear was both too eager and too
destructive:

- A `fenced` provenance record (torn provenance file, or a crashed pending
  write) turned the grace window into a logout of the account the mirror
  actually and correctly held.
- The pre-provenance fallback was tautological: initializeLastSyncedState
  seeds lastSyncedAccountId with the SELECTED account, so
  `missing && lastSyncedAccountId === id` is true on every cold start —
  the wrong-account launch still reproduced on installs with no
  provenance file.
- The pre-delete read-back only rescued managed-owned bytes, so a
  system-default refresh living only in the mirror was destroyed with no
  path back to ~/.codex.
- rmSync was unguarded on a branch whose entry condition is "the
  filesystem refused a read", so an EPERM/EBUSY lock could throw out of
  prepareForCodexLaunch, and the fence was written after the delete.
- Swallowing managed-home read errors let an ambiguous candidate set
  collapse to a single match and be written to the wrong home.

Ownership is now decided from the credential's own identity claims
(codexAuthMatchesManagedAccount against the account record, no read of the
unreadable home), then from the exact bytes Orca mirrored this run, and
only then from provenance. The clear rescues a mirror-only system-default
refresh to ~/.codex first (reusing the ownership rules
syncRuntimeAuthWithSystemDefault already applies, now shared via
resolveSystemDefaultMirrorClaim), fences before deleting, and never throws.
An unreadable managed home whose record cannot rule it out now makes the
read-back scan ambiguous instead of misattributing the bytes.

Five tests added; each fails on the previous commit and passes here.

* fix(codex): never let the fence cancel the delete, or unattributable bytes cause a logout

Round-2 review found the previous response commit walked the guard backwards
in three places:

- `fence before deleting` put persistSharedRuntimeAuthProvenance and rmSync in
  one try, so a fence write the OS refuses (Windows AV lock on the provenance
  file) cancelled the delete and the launch ran with the other account's
  credentials — the original defect, reproduced. The delete now runs from a
  finally: the fence is still attempted first, but neither it nor the read-back
  rescue can skip it.
- Credentials with no OAuth identity claims (apikey/agentIdentity/PAT/bedrock,
  all first-class in managed-codex-auth-readiness) can never satisfy a positive
  ownership proof, so a pre-provenance install deleted the mirror it correctly
  held during a transient absence. With no committed provenance record left,
  ownership is now refuted rather than proven: only claims that contradict the
  account rule the mirror out, and claims a credential does not carry
  contradict nothing.
- A stale record email hard-failed the proof even when the account id matched
  exactly. Orca only refreshes account.email on add/re-auth, so a renamed
  ChatGPT account kept a stale email indefinitely and both lost its mirror and
  had its refreshes dropped instead of read back. A positive account-id match
  now outranks the email, in one shared rule both identity predicates use.

Four tests added; each fails without its fix.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-03 23:25:56 -07:00
OrcaWinandOrcaWin ed7849eb7b fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash (#12406)
* fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash

#6967 derived the Windows setup-runner shell from `terminalWindowsShell`. On
upgrade, any Windows user whose terminal preference resolved to Git Bash had
their existing `orca.yaml` setup script (and issue command) handed to bash
instead of cmd.exe. Scripts authored against the cmd runner — `copy`, `xcopy`,
`set VAR=value`, `if errorlevel 1`, `%VAR%`, backslash paths — broke with no
migration and no warning, and the failure looked like Orca broke the project.

The conflation is also wrong in the steady state: a terminal preference is
per-user, so two people on the same repo got different interpreters for the
same orca.yaml and no project could write a setup script that worked for all
of its Windows contributors.

The interpreter is now a property of the script, declared the standard way:
a leading `#!` line. Native Windows keeps the historical `.cmd` runner unless
the script declares a POSIX shell, so no existing script changes behavior.
`resolveSetupRunnerShell` keeps its role as the feasibility gate — a bash
runner still requires the terminal to resolve to Git Bash, since the launch
command is typed into that shell and uses MSYS `/c/...` paths.

`buildWindowsRunnerScript` now drops a leading `#!` line rather than `call`ing
it, so a declared-bash script that falls back to cmd (Git Bash missing) fails
on a real setup line instead of aborting on errorlevel at line one.

WSL worktrees, POSIX platforms, and SSH hosts are untouched.

* fix(worktrees): keep the cmd setup runner launchable from a Git Bash pane

Adversarial review of this PR found that pinning the runner format per script
reopened issue #6896 one layer down.

- `WorktreeSetupLaunch.shell` had been redefined to mean "the format the runner
  file was written in". `resolveSetupRunnerCommand` consumes it as "the shell
  that types the launch command", so a Git Bash terminal with a batch setup
  script produced `cmd.exe /c "C:\...\setup-runner.cmd"` typed into a bash pane,
  where MSYS rewrites the `/c` switch into a drive path: cmd opens interactively
  and setup never runs. `shell` is the terminal's family again; the runner file's
  .cmd/.sh extension carries the format, and a batch runner launched from a POSIX
  pane reuses the existing PowerShell ProcessStartInfo launcher.
- The cmd runner dropped a leading `#!` line and ran the rest as batch, so a bash
  script reaching cmd (PowerShell/cmd terminal, or any SSH-to-Windows host) got
  its interpreter-agnostic prefix executed before failing mid-way. It now prints
  why and exits 1 without running anything.
- A `#!` line's option flags were discarded: `#!/usr/bin/env -S bash -euo
  pipefail` lost pipefail because the runner is launched as `bash <path>`. The
  generated posix runner now replays declared flags via `set` and drops the
  duplicate interpreter line.
- Docs cover the per-user setup command in repository hook settings, which goes
  through the same `#!` rule, and describe what the `#!` line does and does not
  select.

Tests: composed launch command for a POSIX pane + cmd runner (hooks, shared
runner command, setup sequencing gate, observed-setup signal), the cmd runner's
shebang refusal, and shebang flag replay. Each fails with the source reverted.

* fix(worktrees): replay only real `set` flags and keep the gate in the pane's shell

Two round-2 review findings:

- `#!/bin/bash -l` replayed `set -l`, which exits 2 and aborted the runner under
  its own `set -e` before a single setup line ran (all platforms). Only the flags
  `set` documents are replayed now; a bare `-o` with no option name is dropped
  instead of dumping the shell-option table.
- The wait-for-setup gate picked its language from the runner file, so a batch
  runner launched from a Git Bash pane got the PowerShell gate while the agent
  startup command was already POSIX-quoted — `Invoke-Expression` cannot parse
  `'\''`. The gate now follows the pane; the runner still launches through the
  ProcessStartInfo launcher, never through bash.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:50 -07:00
OrcaWinandOrcaWin 141b1f43f6 fix(runtime): keep the listener on loopback for a "This computer only" pairing link (#12405)
* fix(runtime): keep the listener on loopback for a "This computer only" pairing link

The runtime pairing URL handler called ensureNetworkExposure() for every
offer, including one whose advertised address is loopback. Settings ->
"Share this Orca server" offers a "This computer only" radio that pairs
against 127.0.0.1 precisely so nothing is reachable off-host, yet choosing
it rebound the WebSocket listener from 127.0.0.1 to 0.0.0.0 — and the widen
never narrows back, so the runtime stayed exposed to the whole LAN for the
rest of the process after the user picked the option that exists to avoid
exactly that.

Gate the widen on the advertised address: only a non-loopback endpoint (LAN,
Tailscale, custom host) needs a listener reachable off this machine, so those
paths keep widening exactly as STA-2370 intended. A loopback link is already
served by the loopback listener, so it now mints without touching the bind.
Classification reuses the shared pairing-address classifier, which also covers
localhost, ::1 and 127.0.0.0/8 typed into the custom-address field.

Tests: a real OrcaRuntimeRpcServer driven through the IPC handler asserts the
bind host stays 127.0.0.1 after a local link and flips to 0.0.0.0 after a
LAN one, plus handler-level cases for 127.0.0.1 / localhost / ::1.

* fix(runtime): gate the pairing widen on the user's declared reach, not the address shape

Review of #12405 found two ways the loopback fix misbehaved.

1. The guarantee died at the next launch. resolveInitialWebSocketBindHost()
   binds 0.0.0.0 whenever any device has lastSeenAt > 0, and MobileSocketWiring
   stamps that for EVERY authenticated socket — including the local browser
   opening a "This computer only" link. So the runtime was still published on
   every interface, one restart later. Grants now carry the reach they were
   minted for (DeviceEntry.pairingReach, persisted); a this-computer grant no
   longer counts as proof that an off-host client may reconnect. Registries
   written before the field default to network reach, so an already-paired
   phone still finds a wide listener after upgrading. A pending grant that is
   re-advertised for the network widens (never narrows) so its link survives.

2. The widen was gated on the shape of the typed address, which the renderer
   never sent the intent for. A Custom `127.0.0.1:8443` — the documented SSH
   tunnel / reverse proxy field — skipped the widen and produced a dead link,
   while `localhost:8443`, `[::1]:6768` and `ws://127.0.0.1:6768` widened, so
   the same loopback intent was handled three different ways. The renderer now
   sends the declared reach ('this-computer' | 'network') and main gates on it;
   the address is only used as a mismatch guard (a this-computer reach carrying
   an off-host address still widens rather than minting an unreachable link),
   resolved through resolveAdvertisedPairingHostname so every accepted address
   form classifies identically.

Also corrected the ensureNetworkExposure invariant comment: the widen is no
longer confined to the first pairing action, so it can now tear down live
loopback sockets — they reconnect on the reused pinned port.

Tests: reach-form matrix + tunnel/undeclared/mismatch cases in mobile.test.ts,
real-server relaunch bind for both reaches, legacy registry compatibility, the
pending-grant reach upgrade, a live-client port-stability guard, hostname
resolver coverage, and the renderer reach plumbing. Reverting only the source
fails 18 of them.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:44 -07:00
OrcaWinandOrcaWin dbb7804421 fix(updater): re-prove the retained Linux package before a privileged retry (#12395)
* fix(updater): re-prove the retained Linux package before a privileged retry

The recovery card's "Try Automatic Install Again" handed electron-updater the
cached .deb/.rpm path with no re-verification. That path is user-writable, so
the digest proven when the card rendered says nothing about the bytes dpkg or
rpm would read as root minutes later — and the card's other actions (copy
command, reveal) validated while the one that actually installs did not.

Re-hash the artifact immediately before the install, ahead of any destructive
quit prep, and abort with copy that tells the user to download again. This
narrows the window rather than closing it; only an immutable handoff would
close it, which is a larger change.

Also fix a macOS-only failure this suite gained with the platform-conditional
pre-commit copy: the expectation hardcoded the non-Darwin string, so the suite
was red on any Mac.

* fix(updater): re-prove the retained Linux package on every install path

Review findings on the original fix:

1. The abort force-sent its error status with no staleness guard, so a
   verdict from a hash that outlived its cycle overwrote whatever card had
   replaced it (a fresh 'available' from Check for Updates became a stale
   "package no longer matches" error). Now keyed on an install-cycle
   signature, the same protection failLinuxPackageRecovery already had.
2. 'read-failed' (EMFILE/EIO/EACCES mid-stream) was described as a digest
   mismatch and tore down the recovery card. It now reuses the accurate
   per-reason copy and keeps the card, exactly as the Copy/Show paths do
   for the same reason. It still fails closed: chmod 000 on a swapped file
   would otherwise be a one-line bypass, since root can read what we cannot.
3. The check was keyed on the recovery status, so it only covered the retry.
   The primary 'downloaded -> Restart to Update' install, whose window is
   hours rather than seconds, handed the same user-writable path to dpkg/rpm
   unverified. Moved into performQuitAndInstall keyed on the tracked
   artifact, so both paths are covered; non-Linux keeps its exact timing
   through a synchronous artifact guard.
4. The async prologue had moved the "quit timer is always cleared"
   invariant out of a try/finally. The re-proof now owns a flag cleared in
   finally, and a rejection fails closed instead of wedging the updater.
5. The install re-proof no longer joins an in-flight validation, so its
   proof cannot predate the click that asked for it.
6. The retry button gained the pending affordance the other actions have,
   since the click now streams the whole package before anything happens.

Tests: real packages are staged in a real updater cache for the whole Linux
block (a path that never existed would now abort every install); new cases
cover the swapped primary install, the stale-verdict drop, the preserved
card on read-failed, the rejecting re-proof, the concurrent second click,
and the fresh-hash guarantee. Each was verified to fail with only its
source change reverted.

* fix(updater): tell the renderer when a stale verdict abandons the install

The cycle guard that stops a stale digest verdict from clobbering a newer
card also withheld the only signal the renderer has that the restart was
called off. The preload abort relay keys on an 'error' status, so with the
status suppressed the window stays restart-prepared for the rest of the
session: Terminal/Settings skip their unsaved-work prompts and the shutdown
checkpoint stays deduped, so a later real quit stages no fresh snapshot.

Push the abandon from performQuitAndInstall's single return-false site, so
it cannot depend on what the reporter decides about the status text, and
relay it to the existing relay.abort() (a no-op unless the renderer armed
a restart). The status stays cycle-guarded exactly as before.

Tests: the stale-verdict and swapped-primary-install cases now assert the
push, a committed install asserts its absence, and the preload relay test
covers the new channel. Each fails with only its source change reverted.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:26 -07:00
OrcaWinandOrcaWin 20a2901677 fix(worktree): tell the truth about live PTYs, and offer force for a wedged sweep (#12394)
* fix(worktree): tell the truth about live PTYs, and offer force for a wedged sweep

Two gaps in the #11960 force path:

The delete toast described every unstopped-PTY failure as "could not confirm
every terminal has exited", including the case where verification positively
watched them running. Force Delete proceeds either way, so the user was being
asked to waive a doubt that did not exist while a running agent's uncommitted
work died with it. The live verdict now gets copy that says so.

A sweep that rejects before any per-PTY verdict exists (wedged daemon, dropped
SSH channel) fails with a teardown-timeout message that the force classifier did
not recognise, so no Force Delete button appeared — the exact dead end #11960
set out to remove. That error now carries the shared prefix and classifies.

* fix(worktree): close the sweep-rejection wedge and stop racing the delete

Review of #12394 found the fix covered only half the wedge it named, and
routed users into a force path whose own safety comment was untrue.

1. Only the outer deadline was classifiable. When a provider *rejects* the
   sweep — dropped SSH channel, erroring daemon — settleBeforeDeadline
   rejects with the provider's original error, which carries no marker, so
   classifyWorktreeForceDeleteReason still returned null and no Force Delete
   button rendered. That is the exact case #11960 named. A rejected sweep on
   the destructive path is now reworded through the existing unstopped-PTY
   prefix (provider text preserved, original kept as `cause`), so old and new
   clients alike classify it as 'unstopped-pty'.

2. Force could delete files while a sweep was still running. The deadline
   rejects without cancelling run(), so allSettled resolved with shutdown()
   still in flight — by construction the deadline error can only fire while
   something is in flight. Force then deleted the directory a live PTY still
   held open (EBUSY / half-delete on Windows and WSL). Sweeps are now tracked
   so the forced path waits for the abandoned work, bounded by a 2s grace;
   force never wedges, and when the grace expires the warning says handles may
   outlive the delete instead of implying the sweep finished.

3. The toast test named for the classifier passed the reason in as a literal,
   so it never exercised it. It now derives the reason exactly as the store
   does, and fails against main.

4. Added the missing unstoppedPtyLive key to the English catalog.

5. isProvenLivePtyRemovalError anchored the 'still live:' marker to the detail
   separator, so a worktree path can no longer spell out a live verdict and
   flip the toast to the destructive copy.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:21 -07:00
1ef83986a1 fix(codex): stop a surviving descendant wedging the Codex home process lock forever (#12410)
* fix(codex): stop a surviving descendant wedging the Codex home process lock

The per-Codex-home process lock was released only by the child's 'close'
event. 'close' fires when the child AND every process that inherited its
stdio have exited, so a grandchild that outlives codex (an MCP server, a
helper, an `sh -c` wrapper) keeps the pipe open and 'close' never arrives.
killProcessTree only SIGKILLs the direct child on POSIX, so Stop and the
60s generation timeout both leave the lock held. Every later AI commit
message, PR field, branch name, and quota probe for that home then queued
forever with no error and no recovery until the app restarted.

Release the hold on 'exit' as well as 'close': once the codex process
itself is gone it can no longer rotate that home's auth.json, which is the
race the lock exists to prevent, so waiting on descendants bought nothing.
As a backstop for a child that never reports exit at all (a kill that does
not land), bound the hold: while another run is queued behind it, a hold
that outlives CODEX_HOME_PROCESS_LOCK_MAX_HOLD_MS releases the queue. The
cap is armed only when someone is waiting and only counts time the entry
actually holds the lock, so uncontended runs arm no timer and queueing
behind a slow predecessor never shortens a run's own budget.

* fix(codex): preserve home lock exclusivity

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-03 23:25:14 -07:00
OrcaWinandOrcaWin bdafe840f7 fix(browser): keep the chrome inset when a page viewport shell is rebuilt (#12401)
The chrome inset that pushes a browser guest below the toolbar was applied
only from BrowserPane's layout effect, whose deps cover the first mount
(page id, active, paintable, slot readiness). Every path that rebuilds the
viewport shell without re-running that effect therefore produced a shell
with a zero-height inset: guest recovery replacing a lost guest
(replacePersistentWebview -> removeBrowserPageViewport -> render-time
ensureBrowserPageViewport), a session-profile switch that recreates the
guest for a new partition, and an overlay slot-root remount (STA-3228).
The recovered page then painted underneath the browser chrome until an
unrelated resize or tab switch forced a recompute.

Make the geometry a property of the viewport instead of the mount: remember
the last synced inset height per page and apply it whenever a shell is
built. This also fixes the ordering case where the chrome is measured
before the shell exists (the height used to be dropped on the floor).

Test: browser-page-viewport.test.ts covers the rebuild-after-replacement
and measured-before-shell cases; both fail without the change.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:08 -07:00
OrcaWinandOrcaWin 0f052a5010 fix(editor): stop Cmd/Ctrl+Shift+T reopening a phantom editor tab (#12399)
`openFile` honored a recently-closed snapshot's `reopenId` whenever no
open file currently held that id, without checking whether the same
(path, mode, owner) was already open under a different id. Editor ids
are not symmetrical across worktrees: the first worktree to open an
absolute path gets the bare path as its id, and any other owner of the
same path gets a namespaced `editor:<wt>:<runtime>:<path>` id. So a
snapshot captured under one shape could be restored while the live tab
carried the other.

When that happened `openFile` took the `existing` branch and mapped over
`openFiles` looking for the stale id, matching nothing — no OpenFile was
created or updated — yet it still set `activeFileId`, the per-worktree
active id, the tab bar order and a unified tab entity to that stale id.
The user got a tab rendering nothing and a dangling active editor.

Resolve the id from the already-open file whenever one exists, so capture
and restore agree on canonicalization. This is a no-op for the non-reopen
path, where `resolveEditorFileIdForOwner` already returns `existing.id`
via the identical predicate.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:02 -07:00
Jinwoo HongandOrcaWin 665b85047b fix(relay): revive a dead relay broker without user interaction (#12432)
Broker deaths that end with closeNow() — an auth refresh failing past
token expiry (laptop sleep) or a transient context read at open — left
no retry timer, so Relay stayed offline until the user clicked Retry or
auth state changed. Adds a dead-man's switch:

- RelayAuthCoordinator.ensureLive(): reconciles only when there is no
  live broker, no scheduled retry, and no open in flight
- DesktopRelayService arms a 5-minute liveness interval and exposes
  ensureLive() for wake signals
- powerMonitor 'resume' triggers ensureLive (sleep is the common cause)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:23:43 -07:00
Brennan Benson ed4d6979b1 fix(app): await durable checkpoints before restart actions (#12433)
* fix(app): await durable checkpoints before restart actions

* fix(app): clear restart latch after refused reload

* fix(persistence): invalidate hash after stale rename
2026-08-03 23:11:36 -07:00
NeilandOrca dbbeca0916 Let a timed-out remote terminal pane reconnect when the host session is still alive (#12213)
* fix(terminal): let a timed-out remote terminal pane reconnect again

A remote-runtime PTY pane that missed the 60s auto-recovery window latched to
"disconnected" permanently, even while the host kept the session alive and
streaming. The cutoff destroyed every path back at once: it dropped the pending
retry and evicted the pane from the shared retry registry (so window `online`
and system-resume became no-ops), and the transport tore down the accepted
host-snapshot listener. The listener was also inert for the common case, since a
host that keeps publishing the same live handle never rotates it.

The cutoff now only stops the retry timer; the pane keeps its pending retry and
stays revivable. The accepted-snapshot listener survives the latch, and a
post-cutoff snapshot is accepted as reattach evidence whether the host rotated
the handle or republished the same one. The require-replacement published-wait
guard is scoped to its own recovery epoch so Reconnect and online/resume are no
longer swallowed.

No new polling: a latched pane still issues zero self-initiated RPCs, and the
same-handle reattach consumes a snapshot the client already receives.

Closes #12097

* fix(terminal): drop the settled attach retry when the recovery cutoff lands

The recovery cutoff now keeps a pending retry so online/resume can revive a
latched pane, but the host-session attach wait schedules a single-shot closure
that the cutoff itself resolves. Retaining it left the pane registered as
revivable work: 'online' would bump the epoch, arm a fresh 60s deadline and flip
the phase to 'recovering' while invoking a no-op, hiding the working Reconnect
button and the same-handle snapshot reattach for a full minute.

The attach wait now discards its own scheduled retry as it settles, so a pane
latched on that path stays 'disconnected' and Reconnect keeps working. Retries
scheduled by the resubscribe paths re-enter real work and are unaffected.

Co-authored-by: Orca <help@stably.ai>

* fix(test): type the reattach host snapshot factory so status narrows to 'ready'

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): keep an exhausted remote pane reattachable through Reconnect and resume

Reconnect and the online/system-resume trigger both opened a fresh recovery
epoch, which switched off the accepted-snapshot reattach path (gated on the
'disconnected' phase) for 60s while the require-replacement inventory wait
dead-ended without scheduling anything.

Gate the same-handle reattach on a spent auto-recovery window instead of the
live phase, consumed once per window, and park an unarmed retry at the
require-replacement dead end so online/resume/Reconnect have work to revive.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): pin latched-pane retention to one listener and one registry entry

The recovery cutoff now keeps the retry-registry entry and the accepted-snapshot
listener alive, so cover the two module-global collections that could accumulate:
destroy/detach cycles, concurrent latched panes, revive storms and snapshot churn
all return to baseline.

Co-authored-by: Orca <help@stably.ai>

* refactor(terminal): use the returned reattach epoch and pin the no-RPC claim

Addresses both CodeRabbit nitpicks: subscribeToHandle now takes the epoch begin()
returned rather than re-reading currentEpoch, and the latched attach-wait test
asserts the runtime call count is unchanged instead of only the advanced count.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 23:05:22 -07:00
외계공룡andchucoding dc0cb1806a feat(repo-icon): expand the project emoji picker (#7989) (#12058)
Replace the hardcoded 12-emoji grid in the repo icon settings with the full searchable, category-navigable emoji picker, reusing the existing emoji-picker-react dependency in a lazily-loaded chunk.

Every pick is re-validated through sanitizeRepoIcon, so an over-cap ZWJ/skin-tone sequence surfaces a toast instead of silently no-op'ing. Skin tones stay selectable.

Maintainer follow-up: use lazyWithRetry so a failed chunk cannot permanently blank the Settings page, disable autoFocusSearch on this inline picker, scope font-family to descendants (the library sets sans-serif on every child), cover the uncovered dark-mode picker variables, match the app scrollbar, and assert the result in the DOM per tests/e2e/AGENTS.md.

New strings are translated in es/ja/ko/zh.

Co-authored-by: chucoding <chucoding@users.noreply.github.com>
2026-08-03 23:00:02 -07:00
e59a319ffe fix(sidebar): keep each project's entry-point workspace visible under "Hide sleeping" (#12257)
"Hide sleeping" swept each project's main workspace out of the sidebar as soon as
it had no live PTY, browser tab or agent — even with "Hide default branch" off.
For a project whose only row is that workspace (a folder workspace, a fresh
clone, a detached-HEAD main), the entire project vanished with no in-place way
back.

Adds a shared `isSleepingSweepExemptWorkspace` predicate keyed on
`isMainWorktree` rather than the branch name, so folder workspaces (no branch),
detached-HEAD mains, and SSH rows whose head/branch are blanked while a provider
is disconnected all stay put. Wired into `computeVisibleWorktreeIds` (sidebar,
Cmd+1-9, workspace board), the jump palette's duplicate inline pass, and mobile's
`filterWorktrees`.

Ships default-on with an escape hatch: a persisted
`alwaysShowDefaultBranchWorkspace` setting surfaced as "Except default branch"
under "Hide sleeping". Explicit "Hide default branch" still wins, since it
filters before the sleeping sweep.

Mobile reads the setting but never writes it back, so a desktop opt-out can't be
clobbered by a filter tap before the ui.get roundtrip lands.

Combines the two PRs open against #8873. #8966's exempt set is a strict subset of
this one, so its production diff was subsumed rather than ported; its jump-palette
render harness and e2e spec were carried over, and are the only such coverage here.

Fixes #8873
Closes #8966

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:55:17 -07:00
NeilandOrca 0927b9c156 fix(gitlab): load pipeline job traces in the Checks side panel (#7732) (#12266)
* test(repro): demonstrate #7732 GitLab pipeline job details never load in Checks panel

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): load pipeline job traces in the Checks side panel (#7732)

Expanding a GitLab pipeline job in the Checks panel always showed
"No inline details are available for this check.": the mapper dropped the
numeric job id, `PRCheckDetail` had nowhere to carry it, and every consumer
called the GitHub check-runs API, which returns null for a GitLab job.

- carry `gitlabJobId` on `PRCheckDetail` and add the `gitlab-job:` branch to
  all three identity ladders (panel rows, editor tabs, fix-prompt keys) so
  same-stage jobs with no web_url stop colliding
- add a runtime-routed trace client so SSH/remote workspaces work, not just
  local IPC, and thread the MR's `projectRef` for fork pipelines
- bound the trace in main via the existing `sliceCheckLogTail` (now shared,
  not GitHub-only) so a multi-megabyte CI log never crosses the 1 MB
  transport frame cap; strip ANSI/section markers up to the CR only, which
  keeps each section's visible header and command echo
- render the excerpt inline instead of "Log tail available in full details."
- feed GitLab traces to "Fix with AI", which previously sent bare check names
- skip the fetch for jobs that cannot have a trace (created/manual/skipped)
  so GitLab's 404 does not replace the benign empty state, and re-arm a
  failed load when the job's state changes since the panel has no retry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): treat a missing job log as an empty log, not an error (#7732)

Round-1 review follow-up.

- a job canceled before it started (or whose log was erased/expired) is
  `completed`/`cancelled`, so the panel fetched its trace, GitLab answered 404,
  and `classifyGlabError`'s issue-edit copy ("Issue not found — it may have been
  deleted.") landed verbatim on the auto-expanded check row; main now maps that
  404 to an empty trace so the row keeps its benign empty state
- keep a missing project a real error (GitLab masks unauthorized projects as
  404) and add `classifyJobLogError` so 403/unknown failures stop borrowing
  issue-edit wording on a job-log read
- broaden the empty-log copy in all five catalogs: it now covers erased and
  expired logs, not only jobs that never ran
- e2e: derive the repro screenshot dir from `process.cwd()` (or an env
  override) instead of a hardcoded POSIX path to a throwaway worktree
- bound the raw trace before the ANSI/section passes so a multi-megabyte log
  is not scanned in full on the main-process event loop
- drop the redundant `if (repo)` in `handleFixChecksWithAI` and the now-dead
  "Log tail available in full details." catalog entry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): address review — project ref on reload, retry re-arm, IPC timeout

- Carry the MR's GitLab project ref on the check-details tab so reloading a
  fork/cross-project job tab fetches the trace from the pipeline's own project.
- Re-arm the sidebar retry when a details load resolves to null, not only when
  it throws; a detail-less row otherwise never retried after the job moved on.
- Bound the local `gl.jobTrace` IPC call with the same 30s timeout the runtime
  RPC path uses — glab runs without a subprocess timeout in main.
- Document that the trace 404 -> empty-log mapping is deliberately broad.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:48:37 -07:00
NeilandOrca ba83a71e30 fix(terminal): apply the running-process close confirmation to every tab close path (#10142) (#12272)
* test(repro): demonstrate #10142 tab X close bypasses running-process confirmation

Unit repro: closeTerminalTab (the X-button/middle-click entry) never consults inspectRuntimeTerminalProcess and drops a tab with a live child.
E2E repro: Cmd+W shows 'Stop running command?' for a tab running sleep 300; cancelling then clicking the tab X closes it silently.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): confirm running-process close on every tab close path (#10142)

The tab-strip X button, middle-click and the tab context menu closed a
terminal with a live child process without asking, while Cmd+W raised
"Stop running command?" for the same tab. The probe lived only in
TerminalPane's pane-level close handler; every mouse entry point reaches
closeTerminalTab(), which guarded pinned tabs and nothing else.

Move the decision into closeTerminalTab, above the web-runtime branch so
paired/remote host-backed tabs are covered too, and give the last-pane
keyboard close back to it instead of probing twice:

- running-terminal-close-guard.ts probes every live PTY of the tab and
  fails open on a rejected probe or a stale remote handle, matching what
  Cmd+W already did. No live PTY ids => fully synchronous close, so idle,
  parked and hibernated tabs keep today's behavior.
- shouldConfirmRunningTerminalClose keeps lifecycle echoes, bulk closes,
  CLI/RPC closes and the post-confirmation re-entry off the modal path.
- A standalone confirm store drives RunningTerminalCloseDialog, which
  reuses the existing CloseTerminalDialog (no new user-visible strings).
  The request carries the tab label because a tab-strip close can target
  a tab the user is not looking at, and dedupes by tab id.
- TerminalPane.handleRequestClosePane now delegates the last pane to
  closeTerminalTab. Its transport ptyId is nullable by design, so the old
  path silently skipped the prompt mid-reattach; the pane keeps its own
  probe only for closing one pane of a split.
- Agent panes win the dialog copy when a split has both an agent and a
  plain command busy, instead of depending on PTY spawn order.
- Tab-group closeItem ran leaveWorktreeIfEmpty synchronously after a close
  that can now defer; it moves to onClosed and still honors skipEmptyCheck.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): close the running-process confirmation gaps on every path (#10142)

Follow-up hardening on the tab-close confirmation, from review of the first
pass:

- A pinned tab with `confirmClosePinnedTab` off never got the running-process
  prompt on any path, including Cmd+W, which is a regression against the old
  pane-level behavior: the pinned branch short-circuited on pinned-ness alone
  and re-entered with `force`, which the running guard excludes. The pin prompt
  now supersedes only when it will actually appear; with the setting off the
  close falls through to the running guard.
- The probe chain had no `.catch`, so a throw in the decision (a copy-kind
  lookup on a tab id makePaneKey rejects, a store subscriber) left the tab
  silently unclosed with no user feedback. It now fails open, as the pane path
  it replaced did.
- A wedged remote inspect RPC could leave the X button looking dead for its
  full 15s timeout. The probe is now bounded; every close path shares the
  bound, so keyboard and mouse still behave identically.
- The agent-vs-command copy had two resolvers on exactly the keyboard/mouse
  seam this issue is about. terminal-close-copy-kind.ts is now the single
  policy; TerminalPane and the tab-strip guard both call it.
- The running queue is async while the pinned queue is synchronous, so both
  could be pending at once and stack two modal overlays. The running dialog now
  waits for a visible pinned confirmation.
- Deduping a repeat close request dropped the second caller's callbacks; it now
  folds them in, so both closes resolve from one prompt. Ticking "don't ask
  again" also drains queued prompts instead of showing one the user just opted
  out of, and a queued prompt no longer inherits the previous tab's tick.

closeTerminalTab drops its private pinned predicate for the shared
isUnifiedTabPinned, whose only consumer the previous commit had removed.

* test(e2e): wait for `sleep` to own the terminal before closing it (#10142)

The running-process close specs polled `hasChildProcesses` to decide the tab
was busy, but macOS starts the shell under `login`, so an initialising terminal
already reports a child before `sleep 300` runs. Both specs could therefore
press close against a shell that never started the command: the probe correctly
saw an idle terminal and closed without asking, and the adjudicated repro failed
against a correct fix.

Wait for `foregroundProcess === 'sleep'` instead. Assertions are unchanged, and
the repro still fails at the pre-fix baseline (a92d8e0b0d).

* fix(terminal): ask instead of closing when the close probe times out (#10142)

Round-1 review follow-ups.

- The 4s probe bound closed the tab outright, but `inspectRuntimeTerminalProcess`
  gives remote runtimes a 15s RPC timeout: any probe taking 4-15s silently killed
  a running remote command that Cmd+W used to prompt about, and the pane path now
  delegates its last-pane close to this guard. An unanswered probe is unknown, not
  idle, so the timeout raises the confirmation with every pty treated as a
  candidate. Failing open still applies to an *answered* probe (rejection, stale
  remote handle), which is the pre-existing pane behavior.
- The split-pane Cmd+W probe had no bound at all; it now shares the same one, so
  the two paths give the same answer to the same question.
- The renamed regression spec dropped its repro scaffolding: the hardcoded
  /tmp screenshot directory (also a cross-platform path violation) and the
  title/docblock that still described the bug as open.

* fix(terminal): adopt the double-activation guard and layout pty lookup from #10167 (#10142)

Cross-referenced against @innocarpe's #10167, which solved the same issue.
Two things it got right that this branch did not:

- Queue actions now hold off for 350ms after a queued request replaces the
  visible one, matching the sibling pinned-tab confirmation. Without it the
  second click of a double-click aimed at one tab lands on the next tab's
  prompt and kills a running process the user never saw asked about — the
  exact bug class this PR exists to close.
- The pty lookup unions the layout bindings with ptyIdsByTabId. A mounting
  pane is bound into the layout before the liveness map catches up, and the
  store's own teardown collector unions both for that reason, so reading only
  the map let a close slip through that window with no prompt.

Also replaces the render-time ref write that failed React Doctor's
"Ref mutated during render" rule: the queued-request checkbox reset now goes
through CloseTerminalDialog's existing subject-change reset instead of
remounting via key, which keeps the exit animation on one element.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:48:22 -07:00
Brennan Benson 086a5d6495 fix(browser): rerun guest retention after veto release (#12430) 2026-08-03 22:41:14 -07:00
Brennan Benson e43bd6c4ad fix(worktrees): preserve folder PTY owner scope (#12429) 2026-08-03 22:29:19 -07:00
Jihwan KimandOrcaWin 5bd2f59d29 fix(runtime): open files from sibling workspaces (#11369)
* feat(runtime): match files to workspace owners

* fix(runtime): resolve terminal paths through sibling workspaces

* fix(editor): route restored sibling workspace files

* fix remote sibling file ownership routing

* fix(editor): migrate restored sibling file owners

* fix(editor): revalidate restored owner activation

* docs(review): record PR 11369 correction evidence

* fix(editor): reject collision before activation prep

* docs(review): record PR 11369 final correction

* fix(editor): retain projected reconciliation narrowing

* chore(review): keep verification artifacts out of PR

* fix(editor): harden restored owner migration

* fix(runtime): resolve workspace root terminal paths

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 21:41:58 -07:00
오영택 3d6d6dd67d fix(orchestration): scope agent lineage to its owning run (#11203)
Use durable Task-to-Run ownership and current pane, process-incarnation, and Run-generation authority for sidebar agent lineage. Add schema migrations, bounded lookup indexes, fail-closed renderer cleanup, and runtime/RPC regression coverage.

Co-authored-by: Jaeyoung22 <89302528+Jaeyoung22@users.noreply.github.com>
2026-08-03 21:21:03 -07:00
NeilandOrca a6b14eb04c fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112) (#12202)
* fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112)

#12101: a force-killed TUI never emits its DECRST reset, so its armed mouse
mode is latched into the on-disk checkpoint and re-derived into the
replacement process's emulator via the cold-restore history seed -- through
both rehydrateSequences and SerializeAddon's own mode trailer. The revived
bare shell then echoed SGR motion reports at the prompt. Seed a
RESET_MOUSE_REPORTING segment after the snapshot (before the torn escape
tail), only when there is real recovered content so the empty-array
"nothing to recover" sentinel survives.

#12112: agent panes arm a main-side PtyStartupIngress that answered opencode's
startup OSC 10/11 queries synchronously inside node-pty's onData, while the
POSIX tty still had ECHO on. The line discipline echoed Orca's own reply back
out as visible text. Echo suppression existed but was gated on windows-conpty.
Add PtyStartupReplyDelivery: POSIX defers the write off the query's turn and
recognizes its own echo anywhere in a span (bounded, non-destructive); ConPTY
keeps its synchronous write; windows-wsl is byte-identical to before.

Fixes #12101

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): read the slave's ECHO bit before answering a color query

The startup color reply was written into a PTY still in cooked mode, so the
line discipline echoed it back as visible junk (#12112). Whether that will
happen is readable state on the slave rather than something to infer from
returning bytes, so the reply now waits until the ECHO bit is observably
clear instead of guessing at echo shapes.

Two echo sources exist and only one is readable. A `quiet` verdict proves
the kernel will not echo, so it retires the caret projection; readline
echoes a master write in software with the tty already raw, so that
projection stays armed on every path. Scoping `quiet` narrowly is the whole
correctness argument here: reading it as "no suppression needed"
reintroduces the bug at a plain shell prompt.

Polling is bounded by a wall-clock budget rather than an attempt count,
because each probe is a subprocess and a multi-pane restore serializes them
on fork. Withholding measures flat at ~210ms from 1 to 100 panes.

Also resets a cold-restored pane's mouse reporting (#12101). The armed mode
is re-derived from the dead process's own persisted bytes through two
channels, so the daemon seeds a reset into recovered history and the
renderer stops trusting a persisted "live agent" signal after a cold
restore. The reset literals move to one shared profile module.

Fixes #12101
Fixes #12112

Co-authored-by: Orca <help@stably.ai>

* test(terminal): pin the cold-restore reset on the spawn-adopted reattach path

A spawn can be answered with an adopted session, which reaches the reattach
handler by a door that skips the restored-session path. Pin that the cold-restore
signal survives it, so #12101's junk cannot come back through it.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): note why the adopted-reattach snapshot leaves the cursor visible

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): harden startup reply delivery

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 21:00:57 -07:00
50594c55a9 Stop the Windows Orca CLI from crashing when the environment carries both PATH and Path (#12218)
* fix(windows): stop the Orca CLI dying on a duplicated PATH/Path environment

The packaged Windows `orca.exe` launcher read
`ProcessStartInfo.EnvironmentVariables`, whose lazy getter copies the
case-sensitive process block into a case-insensitive dictionary via `.Add`.
An inherited block carrying both `PATH` and `Path` threw
`ArgumentException: Item has already been added. Key in dictionary: 'PATH'`,
so every `orca` invocation exited 1 before Electron started
(native/windows-cli-launcher/OrcaCliLauncher.cs:46, printed at :67).

The launcher now mutates its own environment with
`Environment.SetEnvironmentVariable` and never touches either
`ProcessStartInfo` env property, so `CreateProcess` passes a NULL environment
block and the child inherits the live one verbatim.

Orca was also minting the duplicate itself. `applyTerminalAttributionEnv`
read `baseEnv.PATH` and unconditionally wrote `baseEnv.PATH`, so a Windows
PTY that inherited `Path` got a second spelling; which one the child resolved
was non-deterministic. `createLaunchEnv` did the same and, because its read
always missed on Windows, shipped Agent Teams terminals a `PATH` containing
only the tmux shim dir.

`resolvePathEnvKey` (extracted from the existing precedent in
windows-environment-path.ts) now drives every PATH read and write in the PTY
env pipeline, and attribution collapses Windows onto the single OS-resolved
spelling. Off Windows the resolver always returns `PATH`, so POSIX behavior
is unchanged and a case-sensitive POSIX `Path` variable is never touched.

Closes #12046

* test(windows): track the launcher's own-environment marker

The #12046 fix moved ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER and ORCA_CLI_COMMAND
off ProcessStartInfo.EnvironmentVariables, but this asset test still pinned the
old dictionary writes and failed.

Co-authored-by: Orca <help@stably.ai>

* fix(windows): follow the host block's PATH spelling on sparse daemon env patches

Resolving a path-less Windows env to `Path` handed the daemon's own
`{...process.env, ...opts.env}` merge both spellings when the host block spelt
`PATH`. Fall back to the host block's own key, and collapse again inside the
daemon since that merge happens after attribution.

Co-authored-by: Orca <help@stably.ai>

* fix(windows): resolve the live PATH spelling by block order, not casing

Win32 resolves a duplicated variable by taking the first case-insensitive
match in the block, so `resolvePathEnvKey`'s hardcoded `Path`-first
preference targeted the shadowed spelling on the reporter's own
`["PATH","Path"]` block. Drop the attribution-side collapse with it: it
deleted the other spelling's value, and deleting the live key promotes
the shadowed one, so an env that stripped down to empty lost both.

* chore: drop unrelated merge formatting

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 20:45:23 -07:00
BingZ 79d3c847bd fix(runtime): attribute destructive close requests (#12238)
Attribute destructive runtime and daemon close diagnostics to the requesting client and exact target, record outcomes only after completion, and add reliability-gated attribution regressions.
2026-08-03 20:35:40 -07:00
Brennan Benson 49dc113a0f Fix terminal corruption after restored snapshot replay (#12363)
* fix(terminal): preserve restored snapshot fidelity

* test(terminal): align legacy history handoff snapshot expectation

* fix(terminal): keep legacy snapshot panes mounted

* fix(terminal): refresh snapshot capability after startup

* fix(terminal): refresh snapshot capability in degraded startup

* fix(terminal): await snapshot provider authority
2026-08-03 20:00:28 -07:00
OrcaWin 0db12feee8 fix(runtime): deliver subscription close when retiring a remote transport (#12384) 2026-08-03 19:44:30 -07:00
Brennan Benson c052ca10a3 fix(gitlab): expire project-ref negatives instead of caching them forever (#12390)
GitLab's project-ref cache stored `null` forever and returned any cached
value straight from the map, so a repo probed before `origin` was configured
— or before `glab auth login` ran for its self-hosted host — kept
hosted-review provider detection stale until app restart. The negative-TTL
work that shipped for Azure DevOps / Bitbucket / Gitea skipped it.

Mirror `createRemoteRefProbeCache`'s semantics: negatives expire on the
shared interval, positives stay, the SSH provider generation joins the cache
signature so a reconnect re-asks, and a probe abandoned as stale can no
longer publish over its successor. Transient git/SSH failures stay uncached.

Expiring negatives would otherwise turn `glab auth status --hostname` into
one spawn per repo per interval on the hosted-review poll, since a non-GitLab
remote reaches it too, so remember the unauthenticated answer per host — not
per repo — on the same clock.
2026-08-03 19:32:00 -07:00
Jinjing a69ca6d5b7 fix(ui): always show Onboarding and Restart Orca in Help (#12379)
* fix(ui): always show Onboarding in the Help menu

Users could not find "Show onboarding again" because it required holding Alt. Surface it on the default Help menu so replay is discoverable without reinstalling.

* fix(ui): always show Restart Orca in the Help menu

Surface Restart next to Check for Updates by default and drop the Alt-only admin gate, which no longer hid anything else.
2026-08-03 19:23:22 -07:00
Brennan Benson 64b81cde50 perf(runtime): memoize mobile snapshots for mounted terminal worktrees (#12383)
The per-worktree rebuild memo from #12207 refused to skip any worktree
with a registered TerminalPane, because builders read live PaneManager/DOM
state the memo could not witness. Hidden worktrees stay mounted (8 parked
+ 12 retained by default, unbounded with terminalHiddenViewParking off),
so that set rebuilt on every publication — on the always-on hosts the memo
targeted, ~20 worktrees paid the full content build plus the new inputs
build and deep compare every tick.

Capture the live reads instead: snapshot pane leaf ids, the live active
leaf, the serialized pane tree, and per-pane numeric/pty bindings once per
publication into MountedTerminalSurfaceCapture, compare captures by value
in the memo, and have the builders read only the capture. Live state
outside the capture is now unrepresentable in a builder, so the skip stays
provably complete — and a mounted worktree whose panes did not change
reuses its snapshot like any other.
2026-08-03 19:22:49 -07:00
Brennan Benson 194e1a8d4d fix(persistence): make the renderer unload checkpoint durably flush before reporting success (#12387)
The sync before-unload checkpoint staged renderer state and then queued
store.flushPendingAsync() fire-and-forget, so reload/restart/update paths
navigated while the staged session, scrollback and UI state were still
only in memory. Quit is covered by the will-quit flush barrier; those
paths were not.

Keep staging synchronous (no sync durable writes), but record the flush
outcome and expose it on app:await-before-unload-checkpoint. Restart,
updater install and lazy-chunk recovery reload now join that write before
navigating and abort the attempt when it fails or outlives a 20s deadline.
2026-08-03 19:18:40 -07:00
Brennan Benson 9e5bd5fb84 fix(worktrees): fence SSH worktree deletion PTY teardown to the owning host (#12388)
Destructive worktree removal swept PTYs by worktree id alone. Worktree ids are
`repoId::path` and the store keeps one per host, so deleting an SSH worktree
could stop a same-id local (or other-connection) workspace's terminals — or fail
outright with `selector_ambiguous` when two hosts owned the id.

Every destructive teardown now names its owner (resolvedWorktreeId plus the
connection/runtime environment), matching the already-hardened forget-local path:

- IPC `worktrees:remove` (git + folder workspaces)
- runtime `removeManagedWorktree` (CLI/mobile `worktree.rm`, git + folder)
- missing-worktree terminal reconciliation, including its no-provider fallback

The #11960 allowUnverifiedStop force-delete gate is untouched.
2026-08-03 19:18:37 -07:00
Brennan Benson e39cdb897d perf(agent-status): coalesce live status bursts into one render pass (STA-3328) (#12377)
* perf(agent-status): coalesce live status bursts into one render pass (STA-3328)

Each live agent-status event arrives as its own IPC task, so a multi-agent
burst paid one full React render pass per event (200-488ms main-thread tasks
under heavy orchestration). Same-task store commits batch to a single render
pass, so buffer a burst for one 33ms window and apply it in one task. The
leading event still applies synchronously (zero added latency for single
events), only an applied event arms the window (dropped/pending events stay
immediate), and both clear paths purge queued sets so a coalesced event
cannot resurrect a removed status.

* fix(agent-status): preserve queued completion on teardown
2026-08-03 19:14:28 -07:00
Brennan Benson 73e7df7764 fix(browser): track guest recency across kill-switch off, release download veto on interrupt (#12232)
Two robustness follow-ups from the #12194 safety-review loop (pass 3),
staged by the reviewer before its session ended; both fail toward extra
retention only:
- recency bookkeeping now runs while the retention kill switch is off, so
  re-enabling evicts in true LRU order instead of worktree-list order
- interrupted downloads release the eviction veto instead of pinning the
  worktree as downloading forever
2026-08-03 18:52:48 -07:00
OrcaWinandBrennan Benson ce8b778d31 perf(runtime): withhold unchanged mobile snapshots from the graph payload (#12245)
* perf(runtime): withhold unchanged mobile snapshots from the graph payload

Every graph sync structured-cloned all 222 worktree snapshots to main even when
none had changed: 374 KB and ~5 ms per clone, paid twice because Electron clones
on serialize and again on deserialize. That transport cost — not the renderer
rebuild — is the bulk of a publication.

The renderer now sends only the snapshots main has not acknowledged and names
the rest in unchangedMobileSessionWorktrees. Detection is object identity, not a
deep compare: an unchanged worktree already returns its cached snapshot object.
Main seeds nextWorktrees from that list so its prune keeps withheld worktrees
live instead of removing them.

The call itself is unconditional. syncWindowGraph is not a one-way publish — its
return value is the only channel carrying agentOrchestrationByPaneKey to the
renderer, and the handler adopts pre-allocated handles, merges detached leaves,
refreshes writable flags, and drains graph-sync callbacks on every sync. Skipping
it would starve all of that.

Two failure modes are closed explicitly. The memo advances only after main
acknowledges, so a publication that throws is resent in full rather than
silently withheld forever. And a worktree main dropped on its own — worktree
metadata removal — comes back in mobileSessionResyncWorktrees, which also clears
the accepted-revision record so the republish is not rejected as a no-op.

Unchanged republish at 222 worktrees / 787 tabs: 374 KB to 3.4 KB, 5.08 ms to
0.02 ms per clone. One changed worktree: 5.3 KB.

* fix(runtime): resync stale withheld mobile snapshots

* fix(runtime): align accepted mobile snapshot membership

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-03 18:11:30 -07:00
Brennan Benson 0586bab4f9 fix(mobile): bound terminal viewport resubscribe loop with backoff (STA-3337) (#12362)
* fix(mobile): bound terminal viewport resubscribe loop with backoff (STA-3337)

An empty scrollback frame with absent host dims was coerced to 80x24, which
never equals a phone viewport, arming a zero-delay unsubscribe/resubscribe
loop (~25/s) that broke long-press gestures and drained battery.

- Absent host dims now hold the stream instead of resubscribing.
- Fit resubscribes are budgeted per handle (3 attempts, escalating backoff)
  with an absence-gated refill mirroring the chat-side rearm bound; on
  exhaustion the view degrades visibly via toast instead of hot-looping.
- A fresh post-measure match counts as convergence instead of resubscribing.
- setTerminalModes keeps the Map identity when the mode is unchanged, so
  same-mode frames no longer re-render the session route.
- Host emits the subscriber viewport as scrollback dims when the snapshot
  and PTY size are both unavailable, so current hosts converge immediately.

* fix(mobile): cancel stale viewport retries after convergence
2026-08-03 17:55:30 -07:00
Jinjing 637c7e94c9 Add SSH config host picker to add-host dialog (#12334)
* feat(ssh): add SSH config host picker for add-host form

Users can now click 'Fill from ~/.ssh/config…' to browse available SSH
config hosts in a picker, select one, and have the form automatically
prefill with resolved connection details (hostname, port, username, auth).

Previously, an 'import' button provided bulk sync on this form—confusing
and unhelpful when everything was already synced. That action is now
available as a secondary 'Add all' option in the picker.

* fix(ssh): import filter preservation and label fallback

- Reuse search loader on import completion to preserve active filter inside generation guard
- Fall back to hostname when manual host has no label, not empty string
- Make alias duplicate detection case-insensitive to match config picker behavior
- Validate host availability when restoring project group selection
- Add aria-selected attribute to picker options for accessibility

* fix(ssh): harden config picker import, alias folding, and host targeting

Review findings on the ~/.ssh/config picker + bulk add:

- Guard config-host resolution with a generation counter so a late resolve
  cannot overwrite a later pick or a form the user backed out of; freeze the
  other rows while a pick resolves.
- Stop "Add all N" from re-adopting deleted hosts — it now imports without
  reAdopt, matching the new-host count it advertises. Settings → Import keeps
  the explicit re-adopt path.
- Fold SSH aliases through a shared normalizeSshConfigAlias for import
  ownership, delete tombstones, reclaim, picker search, and the save-time
  duplicate check, which now occupies configHost *and* label like the picker.
- Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not
  when `ssh -G` merely echoes the /etc/ssh system default.
- Fail closed with unavailable/setup-not-found when an explicit
  projectHostSetupId names a non-actionable host instead of silently creating
  the workspace on a sibling host.
- Cache the parsed config for the picker session (refresh on open/retry) so
  filter keystrokes no longer reparse and Include-expand the file, keep the
  filter usable during loads, add a Retry on load errors, explain an empty
  Identity file after a config fill, and drop the always-false aria-selected.

* refactor(ssh): centralize host result limit and extract folder group val

Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message
cannot drift from the host's query limit. Extract findActionableFolderProjectGroup
to avoid repeating the folder-host-availability check across the composer hook.

* fix(ssh): pass -F to ssh -G when HOME differs from passwd home

In E2E tests and sandboxes, isolated HOME can differ from the system
passwd home. OpenSSH resolves the default config via getpwuid (passwd),
while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F
to explicitly specify the config path when they diverge, so ssh -G and
the picker resolve the same file.

* fix(ssh): verify config host exists before resolving with ssh -G

When a user edits ~/.ssh/config and removes a host, the import picker
should not fall back to ssh -G's echoed response (which treats any alias
as valid). Check the reloaded config file before resolving.

- Force reload config on each resolve to catch user edits post-open
- Reject aliases not in the current config before calling ssh -G
- Add test for deleted alias edge case
- Fix workspace-target fallback to honor explicit host selection

* fix(ssh): let tombstoned aliases be re-picked in the config picker

Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path.
2026-08-03 17:32:13 -07:00
Brennan Benson 9ec4907cfb fix(agent-status): restore hydrated nonterminal statuses as unconfirmed (#12346)
* fix(agent-status): restore hydrated nonterminal statuses as unconfirmed

A hook transition that fires while Electron is down has no receiver and is
discarded, so last-status.json can restore a stale 'working' as confirmed
truth for up to the 7-day hydrate TTL. Stamp hydrated nonterminal rows with
restoredUnconfirmed, carry it through both IPC paths, and treat such rows as
never-fresh in the shared and renderer freshness gates so the sidebar,
worktree.ps, and the raw snapshot all present the same degraded semantics.
Terminal states restore as-is; any accepted live event clears the flag; the
flag itself is never persisted. Interrupt/question inference refuses to
fabricate transitions onto unconfirmed rows.

* fix(agent-status): shed unconfirmed marker when the liveness sweep verifies done

The restored-subagent reaper's reconciled entry spread carried
restoredUnconfirmed onto a process-probe-verified 'done', making freshness
gates suppress a legitimate completion. Keep the marker only while the
reconciled state stays nonterminal.

* fix(agent-status): let live evidence replace hydrated rows

* fix(agent-status): keep restored rows degraded

Sort accepted live evidence after hydrated rows even across wall-clock rollback. Let unconfirmed rows own their preserved pane titles without asserting live state, while retaining independently live sibling evidence.

* fix(agent-status): suppress unmapped restored titles

Treat a single runtime title as covered by the single restored hook row while layout identity is unavailable. Preserve ordinary age-stale fallback and mapped sibling-pane evidence.
2026-08-03 17:22:43 -07:00
Brennan Benson 0ea3c9c31f fix(renderer): drive agent working spinner on the compositor (STA-3328) (#12359)
* fix(renderer): drive agent working spinner on the compositor (STA-3328)

The shared 12Hz JS clock wrote el.style.transform on every mounted spinner
(41 live = ~490 main-thread style writes/s), keeping style perpetually dirty
and delaying keystroke dispatch (measured typing inputDelay p90 363ms; 19ms
with the writes stopped). Replace it with a steps(12) CSS transform animation
so rotation runs on the compositor; one mount-time animation-delay write
preserves the shared-epoch phase sync.

* fix(renderer): lock agent spinner phases to document time

* fix(renderer): tolerate missing spinner animation API
2026-08-03 17:20:27 -07:00
Brennan BensonandOrcaWin f4b2b782b5 feat(orchestration): coordinator-driven release of settled worker terminals (STA-905) (#12355)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 17:17:26 -07:00
Brennan Benson 98e8d1c41d fix(terminal): reconcile status after escape interrupt (#12345)
* fix(terminal): reconcile status after escape interrupt

* fix(terminal): preserve absent interrupt baseline

* fix(terminal): ignore stale input acknowledgments

* fix(terminal): order interrupt acknowledgments
2026-08-03 17:05:04 -07:00
Brennan Benson 13f033f091 chore(daemon): disambiguate audit observations (#12343)
* chore(daemon): disambiguate audit observations

* fix(daemon): reject future audit protocol roles

* fix(telemetry): protect daemon audit observations
2026-08-03 16:28:49 -07:00
JinjingandOrca d7fe9d6bcc fix(ai-vault): support session scanning in SSH worktrees (#11004)
* fix(ai-vault): support session scanning in SSH worktrees

Add relay-native aiVault.listSessions scanning that discovers agent
sessions on SSH hosts. Includes fallback to filesystem crawl for
legacy relays, full cancellation support, result validation, and
scan coalescing to reduce redundant work.

* fix(ai-vault): scan sessions in SSH worktrees with coordinated cancellat

- Extract batching logic to `mapRemoteScanBatches` for reuse and proper cancellation checkpoints
- Move `AiVaultScanCoordinator` from relay to main to handle concurrent same-key requests with individual cancellation signals
- Report scope path truncation consistently across relay and SSH fallback paths
- Gracefully degrade relay handler on unsupported platforms instead of aborting startup
- Refactor issue display to separate blocking errors, scope notices, and skipped transcript counts

* fix(ai-vault): stabilize SSH session scan CI

Swallow async WSL relay stdin EPIPE so the live hook-relay shard no longer
fails after all tests pass. Merge main, resolve scan/relay conflicts, and
align cancellation/host-issue reporting with IPC expectations.

* fix(ai-vault): harden session scan cancellation, relay timeouts, and preemption

Thread the abort signal through every scan and parse path so superseded or
cancelled scans stop promptly instead of parsing every remaining transcript
for a caller that already left.  Replace the fragile message-text relay
timeout check with a typed error code so unrelated errors carrying the
phrase "timed out after" no longer suppress the filesystem fallback.  Fix
scan coordinator preemption so a forced Refresh in one window no longer
re-enters as a spurious cancellation in another.  Add a host-leg cache for
the all-hosts view and cap filesystem concurrency so a single slow remote
home cannot stall the whole merge.

Co-authored-by: Orca <help@stably.ai>

* fix(ai-vault): use stable React keys for scan issue banners

Drop array-index keys so react-doctor/no-array-index-as-key passes.
Uniqueness comes from host, kind, agent, path, and message.

* fix(ai-vault): SSH session scanning with configurable depth limits

Implement depth-aware caching and proper scan boundaries to make SSH session
scanning reliable in worktrees. Users can now select between faster (250
sessions) and comprehensive (unlimited) history scans. The scanner:
- Deduplicates scans across relay, host leg, runtime, and renderer layers
- Reuses larger scans to serve smaller depth requests
- Properly bounds in-scope discovery per-limit
- Fixes timeout enforcement when SSH providers ignore abort signals

* Move sessionLimit ref update to useLayoutEffect

Keep render pure for React Doctor by deferring ref updates to
a layout effect, which still executes before render-dependent
effects that consume the ref.

* fix(adhoc): stamp version prefix from main, not the feature branch

Adhoc builds check out arbitrary refs whose package.json often lags
version bumps (e.g. 1.4.165-rc.0 while main is 1.4.168-rc.1). Hourly
always builds main so it already tracks the product line; adhoc now
resolves the base version from origin/main (or ORCA_ADHOC_BASE_VERSION)
so branch builds share that prefix.

* Revert "fix(adhoc): stamp version prefix from main, not the feature branch"

This reverts commit a26a18eb3fd83f7e7d2db9a6a7c3e02e0f79089a.

* fix(ai-vault): fix scoped backfill and coordinator race conditions

Resolve race where the last waiter leaving could abort an already-settled scan (add `settled` flag). Redesign scoped session backfill to keep searching through newer files until the scope reaches its requested session quota instead of stopping at the candidate limit; out-of-scope files no longer consume the scope budget. Centralize scan limit normalization and fix error classification for cancelled scans using the proper helper instead of checking Error.name. Disambiguate cache keys using JSON and add cancellation check after scope discovery phase.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 16:17:00 -07:00
Brennan Benson a7282fed40 Fix Codex launch draft delivery after delayed setup (#12347)
* fix: preserve Codex launch drafts through setup

* test: enforce single startup draft delivery

* test: name startup draft release behavior
2026-08-03 16:06:29 -07:00
Jinjing 5941c6c9db Improve send comments to ai (#12150)
* Post fixing replies after launching PR comment resolution agents

Orca now automatically replies to selected comments after launching an
agent to resolve them. Review-thread comments get nested replies; review
summaries and issue comments get top-level @-replies. Payloads are
snapshotted at queue time and posted only after prompt delivery
succeeds. Bounded concurrency (4) prevents SSH slowdown.

* Batch conversation replies instead of posting per-comment

Combine N unresolvable comments into a single PR conversation reply to avoid timeline spam. Review threads still get one nested reply each. Refactor helper functions to pr-comment-fixing-reply-body.ts and change replyAsConversation(comment, body) → replyAsConversation(body).

* Handle bot account mentions in PR conversation replies

Use buildPRCommentConversationReplyBody to properly handle GitHub App
bot accounts, which don't resolve as @-mentions. Enforce delivery semantics
with type-safe PendingPRCommentAiAck payloads and improve error handling
around agent launch to prevent stranded state.

* Fix React Doctor ref-mutated-during-render errors in ChecksPanel

Move comment-resolution payload ref clears and latest-callback mirrors
into effects so render stays pure for the static analysis gate.
2026-08-03 15:58:20 -07:00
Jinjing ea68d97c28 Optimize worktree parent picker: conditional mount, virtualization, lazy status loading (#12225)
* Optimize worktree parent picker: conditional mount, virtualization, lazy

- Mount WorktreeParentPickerPopover only when open to avoid hundreds of
  unmounted instances subscribing to lineage and worktree store updates.
- Virtualize the candidate list and resolve activity statuses only for
  visible rows, eliminating redundant status subscriptions.
- Extract filtering, placement calculations, and row rendering into
  separate modules for testability and clarity.

* Memoize worktree parent picker search handler

Wrap search state update in useCallback to stabilize the handler
across re-renders. Reduces unnecessary effect runs and enables better
memoization of child components.

* Optimize worktree parent picker: defer unmount, memoize IDs

- Defer unmount until exit animation completes (200ms) to prevent premature teardown
- Memoize visibleWorktreeIds to prevent status hook from rebuilding its selector on every render
2026-08-03 15:57:05 -07:00
OrcaWinandOrcaWin 25213ec04d test(terminal): harden daemon upgrade adoption (#12353)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 15:03:37 -07:00
Jinjing 031115b0a5 test(ssh): freeze FrameDecoder clock in framing unit tests (#12356)
Default 4ms maxTurnMs can defer later frames via setImmediate under
CI load, so multi-frame assertions after a single feed were flaky.
2026-08-03 15:01:26 -07:00
Brennan Benson 7de40ffbd8 fix(terminal): stop keyboard effect re-registering on every pane render (STA-3291) (#12348)
createExpandCollapseActions minted five fresh closures per TerminalPane
render; four are deps of useTerminalKeyboardShortcuts, so its seven
window listeners tore down and re-registered on every render and all
effect-owned IME chord/modifier state silently reset. Memoize the
actions via useExpandCollapseActions and pin registration stability
with a render-churn regression test.

Fixes #12269
2026-08-03 14:41:22 -07:00
OrcaWinandOrcaWin e5174a452b fix(terminal): reattach panes across daemon protocol upgrade (#12351)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 14:19:44 -07:00
Jinwoo HongandOrcaWin a4944f5343 fix(orchestration): retain update settlement authority (#12336)
* fix(orchestration): retain update settlement authority

* test(orchestration): register update settlement gate

* fix(orchestration): close update settlement audit gaps

* test(orchestration): correct update settlement evidence

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 14:00:27 -07:00
OrcaWinandOrcaWin e25381cdd3 fix(relay): tolerate cell clock skew in pairing invite expiry validation (#12340)
The cell stamps invite expiry at exactly now+10min from its own clock while
the desktop rejected anything past now+10min from the local clock with zero
tolerance, so any cell clock ahead of the machine by more than network
transit made every Relay pairing code fail with an opaque toast. Same
defect class as the host-proof freshness incident; same 30s leeway.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 13:15:30 -07:00