* fix(pty): require proven absence before retiring a stable pane owner
"Session not found" only proves the provider we asked has no such PTY. A
degraded router answers unmapped session ids from the local fallback, which
never owned a daemon session, so a live agent's PTY produced the same error a
dead one does — and the pane was then given a synthetic exit, its durable
pane->PTY binding deleted, and a duplicate spawned. For a single-pane tab the
retirement also drops the tab from persisted state, which the renderer cannot
add back.
Gate the retirement on probePtyLiveness, which polls every possible owner and
answers false only when all of them agree the session is absent. `null` (nobody
could answer) is not absence. Providers without a probe are their own sole
owner, so their refusal stays authoritative and their behavior is unchanged.
Also move the retirement's durability barrier off writeToDiskSync: it fsyncs
the whole multi-MB state from the main thread, and a restore that retired N
dead panes paid that stall N times.
* fix(pty): make the unproven-absence veto transient and route attaches to the real owner
Review found the proven-absence gate correct in direction but terminal in
effect: "probe says alive" and "probe says unknown" both became a hard spawn
failure with no consumer, no retry, and a raw internal token in a toast.
- Graceful teardown (should-fix): a session being killed reports alive
(getAppliedSize answers while isAlive, and isTerminating does not flip
_state) while createOrAttach already throws SessionNotFoundError, so for up
to KILL_TIMEOUT_MS the pane failed instead of retiring the doomed binding.
The veto now re-proves on a bounded 6s budget: the moment any owner answers
"absent" the pane retires and spawns fresh exactly as before.
- Unprovable owners (should-fix): DaemonPtyAdapter.probePtyLiveness never
called ensureConnected, so a merely disconnected adapter answered null
forever and one null poisons probePtyOwners; it also took no deadline, so a
wedged daemon burned the client's 30s request timeout per attach. It now
connects first (like listProcesses) and threads an absolute deadline through
the fan-out. The thrown error is user-legible English instead of
terminal_pane_owner_unverified.
- Durability barrier (should-fix): flushPendingOrThrowAsync is not a twin of
flushOrThrow — it defaults to the drain-to-stable-generation loop the
sibling best-effort caller deliberately avoids, adds active-view and GitHub
sidecar writes, re-serializes state per iteration, and rejects when writes
are frozen where the sync path no-ops. Reverted to flushOrThrow, matching
the sibling retire paths in orca-runtime.
- Misrouting root cause (nit): DegradedDaemonFreshSpawnRouter.spawn sent an
unmapped sessionId to the local fallback and DaemonPtyRouter.spawn sent it to
the current daemon, both faking "Session not found" for a live session. Both
now resolve an existing owner first (the same resolution every non-spawn path
already uses); minted ids still route to the fallback/current daemon. The
pane reattaches instead of only surviving.
Tests: retry-until-proven, teardown-window retire, misroute-then-reattach,
probe deadline threading, unconnected-adapter probe, and both routers' owner
resolution. Each fails with only the source reverted.
* fix(pty): drop the follow-up, keep the minimal proven-absence guard
Reverts aa27eb5274 and leaves only the original guard: retire a stable pane's
owner solely when a provider authoritatively answers absent
(probePtyLiveness === false). `true` and `null` still refuse to authorize
destruction, and providers without a probe keep their refusal authoritative, so
their behavior is unchanged.
The follow-up bought too much surface for a P0, and two of its pieces did not
hold up:
- The `hasPty` owner scan added to DaemonPtyRouter.spawn and the degraded
fresh-spawn router is inert for the case it targeted. `hasPty` reads
`activeSessionIds`, which only spawn/attach populate — `listProcesses`
(daemon-pty-adapter.ts) never does. After a failed discovery the scan
therefore cannot find the owner it was written to find.
- Its two routing tests could not fail: the mocks backed `hasPty` and
`listProcesses` with one shared array, giving the fake adapter knowledge the
real DaemonPtyAdapter provably lacks.
Also dropped: the bounded 6s retry loop and its constants, the sentinel symbol,
the exported user-facing message, the `opts.deadlineMs` widening of
probePtyLiveness and its threading through the probe/routers, and the
ensureConnected change in DaemonPtyAdapter.
Kept from the follow-up: the flushOrThrow revert. flushPendingOrThrowAsync is
not a like-for-like twin (drain-to-stable-generation loop, sidecar writes,
per-iteration serialize, rejects when writes are frozen), so
retirePersistedStablePaneOwner stays synchronous.
One gap the strict one-shot gate does open is closed here. `getSize` landed in
daemon protocol v18, and legacy adapters are created for every version in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS (1..30). A pre-v18 daemon error-replies
"Unknown request type", probePtyLiveness catches it as `null`, and one `null`
makes probePtyOwners unprovable forever — so a genuinely dead pane could never
retire. probePtyLiveness now version-gates getSize and asks such a daemon for
`listSessions` instead, the same inventory legacy discovery already routes by,
requested directly rather than through listProcesses (which swallows errors into
an empty list) so an unreachable socket still answers `null`. Unreachable never
means absent; only an owner that answered does.
* fix(daemon): detect getSize support instead of inferring it from the protocol number
The version gate was not a sound boundary. `getSize` landed in 22b00a7cd2 while
PROTOCOL_VERSION was already 18 and that commit did not bump it, so version 18
means two different things: daemons built before it lack the request, daemons
built after it have it. Both report 18.
A daemon from that window therefore passed the `>= 18` gate, was sent `getSize`,
error-replied "Unknown request type", and answered `null` — and one `null` makes
the owner fan-out permanently unprovable, so a genuinely dead pane could never
retire. That is the same wedge the gate was added to close, just narrowed to one
build window.
Ask the daemon what it supports rather than trusting its number: attempt
`getSize`, and fall back to `listSessions` only when the daemon itself replies
that the request type is unknown. The predicate is deliberately narrow so a
transient failure stays unproven instead of being mistaken for a missing
capability, and the rejection is remembered so later probes skip a round trip
that cannot work. This mirrors the capability-probe rule AGENTS.md already
requires for Git, for the same reason: version numbers overstate capabilities.
Unreachable still never means absent.
* test(pty): pin that proven absence still retires, and correct a wrong rationale
Review found the guard had no positive-direction coverage: every existing retire
test uses a provider with no probePtyLiveness, so the guard is skipped there.
Mutating `!== false` into `!== undefined` — which turns the guard into a
permanent veto, so no daemon-backed pane could ever recover from a genuinely
dead owner — left the whole suite green. Pinned with a case that fails under
exactly that mutation.
Also corrects the fallback's Why-comment. It claimed listProcesses "swallows
errors into an empty list"; it does not — DaemonPtyAdapter.listProcesses
rethrows and the router fails closed. The error-swallowing lives in
discoverLegacySessions / discoverDegradedDaemonSessions. Requesting listSessions
directly is still right, but because a liveness probe should not publish
inventory audit observations as a side effect — not for the stated reason.
* fix(daemon): bound the liveness probe so a wedged daemon cannot stall a pane mount
probePtyLiveness passed no timeout, so each request fell back to the client's
30s REQUEST_TIMEOUT_MS. A wedged daemon holds its socket open rather than
refusing, so it answers neither quickly nor at all — and probePtyOwners awaits
every adapter before it can conclude anything. One hung daemon therefore stalled
each restoring pane for up to 30s before the pane reported that it could not
reopen.
Nearly all local terminals are daemon-backed, so this is the common
configuration, not an edge case.
Bound both probe requests to 2s. Answering "unknown" quickly is strictly better
here than answering slowly: unknown never authorizes retirement, it only defers
it, so the only thing a shorter budget can cost is an earlier retry. The tests
now pin the bound at the call rather than leaving it implicit.
* test(daemon): pin the getSize probe timeout, and reattach an orphaned comment
Review proved a coverage gap by mutation: removing LIVENESS_PROBE_TIMEOUT_MS
from the getSize request left the whole suite green, so nothing stopped that leg
regressing to the client's 30s default. Only the listSessions leg was pinned.
The assertion now fails under exactly that mutation.
Also moves isUnknownRequestTypeError below isDaemonGoneError. It had been
inserted between isDaemonGoneError and the comment describing it, so that
comment read as documenting the wrong function.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
(cherry picked from commit ac7f9a4fe1)
* fix(editor): stop Cmd/Ctrl+Shift+T reopening a phantom editor tab
`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.
* fix(editor): make Close All linear instead of cubic in tab count
closeAllFiles resolved a reopen position for every closing tab by calling
getRecentlyClosedTabPosition, which itself rescanned the worktree's tab-bar
order and group membership with a nested find per entry. That made the whole
bulk close O(N^3) in open editor tabs, and because it runs inside a synchronous
zustand set() the renderer is blocked for the entire duration: measured against
the real store, 500 tabs took 1.2s and 1000 tabs took 13.0s of frozen UI.
Sessions that accumulate tabs — long SSH/relay work, a mirrored host republishing
open files, a folder workspace opened wide — hit this on a single menu click.
Hoist the per-tab rescans into one precomputed index. The new
createRecentlyClosedTabPositionIndex builds tab-bar, unified-tab, and group
lookups in a single linear pass over the worktree's state, then answers each
position query in O(1); getRecentlyClosedTabPosition now delegates to it so
single-tab callers keep identical behavior. Positions, reopen order, and the
tab-bar/group indices recorded by #12236 are unchanged — first-occurrence wins
everywhere, matching the indexOf/find scans this replaces.
Position derivation moves to its own module so recently-closed-tabs.ts stays
under the max-lines limit without a suppression; existing importers are
unaffected via re-export.
Same measurement after the change: 500 tabs 44ms, 1000 tabs 142ms, and the
instrumented scan count drops from 503,000,500 to 4,000 at N=1000.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
(cherry picked from commit e599d924bd)
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.
(cherry picked from commit 64b81cde50)
* 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>
(cherry picked from commit ce8b778d31)
* fix(mobile): keep cached workspace counts across a transient RPC failure
The Home host card showed "12 worktrees · 2 active" until any worktree.ps
failed — a backgrounded app, a Wi-Fi→cellular handoff, or a sleep/resume
that kills the socket mid-request. Two things then went wrong:
- render dropped the counts: `markHomeWorktreeCatalogUnavailable` kept the
proven numbers in state, but the card only rendered them when
`catalogUnavailable` was unset, so the line collapsed to "Worktree list
unavailable" even though the last successful counts were right there.
- nothing re-drove the fetch: the per-host wiring latched a `statsFetched`
boolean on the first connect, and the logical client survives socket
drops, so its reconnect never re-read the catalog. The card stayed wrong
until the user navigated away and back.
Keep the proven counts and flag them stale (`staleCounts`), rendered as
"Last known: 12 worktrees · 2 active"; a host whose catalog never loaded
still reads "Worktree list unavailable" (STA-3123). Replace the one-shot
latch with createHostConnectRefetchGate, which fires on each transition
INTO 'connected' — one refetch per reconnect, no polling timer — mirroring
useWorktreeResync on the host screen. fetchHomeHostWorktreeInfo moves out
of app/index.tsx so its rejection path is covered by tests.
* fix(mobile): bound "Last known" counts and survive a path cutover
Review found two ways the home host card's stale-count fix misbehaves.
1. A migrateTo cutover (relay->direct probe, forced replacement) rejects
in-flight requests with LogicalClientCutoverError and republishes
'connected' from 'connected', so the connect gate never re-arms and the
card latched on "Last known: ..." with nothing left to clear it.
worktree.ps now re-issues on the authenticated replacement, bounded,
like runtime-capability-probe and worktree-create-retry already do.
2. "Last known: N worktrees" had no age bound. The home snapshot is
persisted, so a cold start whose first worktree.ps failed rendered
counts proven days ago exactly like counts proven seconds ago - the case
STA-3123 deliberately rendered as "Worktree list unavailable". Counts now
carry countsProvenAt and expire out of the "last known" wording after
10 minutes; counts persisted by an older build count as expired.
Also, per review: the card derives its own worktree line from
HostWorktreeInfo, so a caller can no longer re-gate the counts away (that
was the original defect), and the derivation is covered by a render test -
mobile/vitest.config.ts never collected *.test.tsx, so component tests
were silently dead. Home stats are keyed by host and summed instead of
letting whichever desktop replied last overwrite the shared header row,
which the per-reconnect refetch made churn on flaky links.
* fix(mobile): age bounds liveness, not the counts; scope the header total to paired hosts
Round-2 review follow-up.
Age bound was anchored on proof time inside the failure branch only, so a
session connected past the window that then hit one failed refresh rendered
the pre-fix "Worktree list unavailable" — the exact case this PR exists for —
while identically aged counts still rendered unlabeled as live whenever the
refresh was merely pending. Age now decides live vs "Last known" and the
failure branch keeps whatever the host last proved; "Worktree list unavailable"
is reserved for a catalog that never loaded.
Header stats summed every entry ever cached, so removing a desktop left its
lifetime numbers in the total for the rest of the session. totalHomeStats now
sums the hosts still paired, which also covers removal from the host screen.
wireHostSubscriptions is the effect body moved verbatim out of useEffect;
react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one
and the changed-code gate has no working suppression path (an inline directive
reads as unused to the plugin-less scan).
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
(cherry picked from commit fb1259a09d)
* 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>
(cherry picked from commit 401f66939d)
* 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>
(cherry picked from commit ed7849eb7b)
* 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>
(cherry picked from commit 141b1f43f6)
* 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>
(cherry picked from commit dbb7804421)
* 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>
(cherry picked from commit 20a2901677)
* 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>
(cherry picked from commit 1ef83986a1)
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>
(cherry picked from commit bdafe840f7)
`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>
(cherry picked from commit 0f052a5010)
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>
The runtime RPC WebSocket listener bound to 0.0.0.0:6769 at startup, so a
desktop with no paired device was reachable from the whole LAN before the
user opted in. Default the bind to 127.0.0.1 and widen to all interfaces
only on an explicit opt-in:
- createMobilePairingOffer / getRuntimePairingUrl widen (ensureNetworkExposure)
before advertising a LAN endpoint; the rebind reuses the resolved port so an
already-issued offer stays valid, and concurrent offers share one rebind.
- orca serve and E2E set exposeNetworkByDefault to bind wide at startup.
- A previously-connected device (lastSeenAt > 0) rebinds wide at startup so
reconnect after restart keeps working; a pending/never-connected offer does
not persist exposure across a restart.
The advertised pairing endpoint still resolves to a concrete interface address,
never the 0.0.0.0 bind host.
* fix(sidebar): route local folder adds to the intended host, not the global runtime
Adding a local folder while connected to a remote runtime failed with
"<path> was checked on <host>, but that host did not report a usable folder"
because addRepoPath decides local-vs-remote purely from the global
settings.activeRuntimeEnvironmentId when no explicit host is passed.
Two local-add flows relied on that global fallback and got misrouted:
- useAddRepoLocalFolderFlow (native picker / drag-drop): the Add Project
host selector can display "Local" (selectedRuntimeEnvironmentId = null, so
the guard passes) while the global still points at an unavailable runtime.
Native-picked/dropped paths are always local, so force local routing.
- AddProjectFromFolderDialog ("Add folder as project" on a subfolder): a
subfolder lives on the active repo's host, so carry that host through the
modal data and route by it — local for local projects, the owning runtime
for runtime projects — instead of the globally-active runtime.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sidebar): route runtime server-path adds by selected runtime, not global
The Add Project "server path" step (reached only when a runtime host is
selected) called addRepoPath(path, kind) with no explicit host, so it
inherited the global settings.activeRuntimeEnvironmentId. When that global
diverged from the dialog's selected runtime, the add was misrouted off the
host the user picked — the same root cause as #9541, opposite direction.
Route the server-path add by the dialog's selected runtime explicitly.
Co-locate selectedRuntimeEnvironmentId in useAddRepoHostSelection next to
selectedSshTargetId so the dialog reads it from one place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sidebar): route the pre-add git server-path scan by the selected runtime
For kind === 'git', scanNestedRepos runs before addRepoPath and can
early-exit the flow into the nested-repo review, but it still routed by the
global active runtime — so it could scan the wrong host even after the add
itself was correctly routed to the selected runtime (CodeRabbit).
- scanNestedRepos accepts an optional runtimeEnvironmentId in its controls;
when present it routes by that host, else falls back to the global
(existing callers unchanged).
- useAddRepoServerPathFlow passes the selected runtime into the scan and
derives runtimeKind/streaming support from it instead of the global-reading
getNestedRepoRuntimeKind(null), so telemetry and the nested review target
the same host as the add.
Adds renderer- and store-level regression tests covering scan routing to the
selected runtime, the null-override local case, and the nested-review handoff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sidebar): make nested-scan cancellation route by the scan's owning host
Follow-up to CodeRabbit review of the scan-routing change:
1. scanNestedRepos treated `{ runtimeEnvironmentId: undefined }` as an explicit
local override via the `in` check. Only null or a string is now explicit;
undefined falls back to the global (matches getAddRepoPathRouteSettings).
2. scanNestedRepos gained a routing override but cancelNestedRepoScan still
routed by the global — an asymmetric contract where an override-routed scan
could be un-cancellable if the global diverged mid-scan. cancelNestedRepoScan
now takes the same override, and useAddRepoNestedReviewState remembers each
scan's owning host by scanId (set when the scan is registered) so both stop
and reset cancel on the host the scan actually ran on. The local folder flow
routes its scan explicitly local so scan, cancel, and add all agree.
Adds store-level regression tests (explicit override wins, undefined falls back
to global, cancel routes by override) and a new useAddRepoNestedReviewState test
covering cancel-by-owning-runtime for stop and reset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sidebar): keep subfolder adds on their owning host
* fix(onboarding): keep completion on captured host
* chore(review): drop unreachable onboarding recovery
* fix(sidebar): preserve paired runtime checkout ownership
* fix(runtime): index paired worktrees by logical owner
* fix: fail closed on worktree owner alias collisions
* docs(sidebar): clarify host-routing intent flagged in review
Two Greptile P2 notes, addressed as comments (no behavior change):
- project-added-default-checkout.ts: the runtime branch's `hostId === executionHostId`
is NOT unreachable — a colliding repo id can carry a runtime-qualified hostId with
no runtimeOwnerEnvironmentId (see the "repo IDs collide" test). Documented why the
comparison is reachable and load-bearing rather than replacing it.
- AddProjectFromFolderDialog.tsx: note that omitting the runtimeEnvironmentId spread
intentionally signals local (NonGitFolderDialog coerces absence to null).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(activity): control portal readiness observer delivery
---------
Co-authored-by: fanyunqian.1 <fanyunqian.1@bytedance.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* test(runtime): prove mobile session publication rebuilds every worktree
buildMobileSessionTabSnapshots consults its per-worktree cache after building
the content, so a republish saves the fanout but none of the work. With 300
worktrees, an unchanged republish still does 601 units of per-worktree work,
and a single changed worktree does 602.
Publication is keyed on agentStatusByPaneKey/agentStatusEpoch, so this runs on
every agent status tick. On a multi-client runtime host with 381 worktrees this
allocated ~350 MB/min and rode the renderer into repeated 4 GB OOMs.
Tests are marked it.fails so the branch stays green; drop .fails when the build
loop skips worktrees whose inputs are unchanged.
* refactor(runtime): make mobile session snapshot inputs explicit per worktree
Every per-worktree builder in buildMobileSessionTabSnapshots took the whole
AppState, so a worktree's real input set was the transitive closure of seven
helpers and could not be memoized safely. Introduce MobileSessionWorktreeInputs
— built once per worktree — and thread it through the group projection and the
terminal/markdown/file/browser tab builders so the compiler proves the input
set. Tab- and pane-keyed slices are narrowed to this worktree's tab ids, file
ids, browser workspace/page ids, and pane keys; agent statuses are bucketed per
worktree once per publication via a tab-id index.
No behavior change. Dropping AppState from the projection path also removes the
second per-worktree read of browserTabsByWorktree, so the publication-cost
counter falls from 601 to 1 per publication and its two cases now pass.
* fix(runtime): skip unchanged worktrees before building mobile session content
buildMobileSessionTabSnapshots consulted its per-worktree cache only after
building that worktree's three Maps, group projection, and full tab array, so
the cache suppressed the fanout but none of the computation. Every agent-status
tick therefore rebuilt every worktree, which drove sustained 4 GB renderer
working sets on a host holding 381 worktrees.
Cache MobileSessionWorktreeInputs alongside each snapshot and reuse the snapshot
when every input field is reference-equal, before any intermediate structure is
allocated. Worktrees with a mounted TerminalPane always rebuild: their live
DOM/PaneManager state is invisible to store references. Absent per-worktree
slices now resolve to shared empty values so an empty worktree can compare equal
to its last publication. jsonContentEquals stays as the backstop on the rebuild
path for inputs that churn by reference without changing output.
With 300 worktrees, per-worktree content builds go from 300 to 0 on an unchanged
republish and from 300 to 1 when one worktree changes.
* test(runtime): cover agent status publication cost
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Bound exclusive host navigation to a generation-aware latest-wins
single-flight so bulk open and switch fan-out stay responsive on large
remote fleets. Add freeze repro harnesses and navigated settlement.
Backspacing away an entire Pinyin preedit ended the composition with
empty data, no textarea residue, and no input/keypress events — yet
_sendPendingComposition fell back to the last non-empty
compositionupdate data and typed its first character into the PTY.
Only trust that fallback when observed input evidence corroborates it;
a composition with no evidence in any channel was cancelled.
Fixes the macOS Pinyin regression from #11293 (stray letter left after
deleting a preedit); same fix covers IBus/fcitx Backspace cancellation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A single slot per code let a rapid second Enter press go unguarded: the
first release found a mismatched timeStamp, dropped the only entry, and
the second release then synthesized the Shift+Enter this guard exists to
prevent. Track one entry per press and drain exactly one per physical
release, so every press stays guarded until its own release. Auto-repeat
keydowns do not stack an entry, since the whole run ends in one release,
and the list is bounded so a press whose release never arrives cannot
grow it without end.
Refs #11878
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On Windows, the Enter-keyup synthesis path inferred the modified-Enter
chord from release-time modifier state. A plain committing Enter
(Process/229, no modifiers) followed by a rolled-over Shift for the next
doubled consonant made the keyup report shiftKey=true and synthesized a
Shift+Enter the user never chorded; a directly-sent Shift+Enter could
likewise send a second newline from its keyup once the next composition
started. Record observed Enter keydowns (code -> timeStamp) and let the
keyup synthesis run only for presses whose keydown the IME swallowed
entirely; a balancing keyup that copies the keydown timeStamp keeps the
evidence for the later physical release.
Refs #11878
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(terminal): expand variables in Windows PATH
* fix(terminal): preserve expanded Windows PATH at spawn
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(mobile): surface worktree catalog failures instead of showing 0 worktrees (STA-3123)
A connected host whose worktree.ps request fails now shows an explicit
catalog-failure state (with the RPC error code) on the host page, and
'Worktree list unavailable' on the home host card, instead of silently
rendering as a healthy host with zero workspaces.
* fix(mobile): mark cached worktree catalogs unavailable
* fix(onboarding): run skill setup in the configured Windows runtime (#12103)
Onboarding was the one skill-setup surface that did not route its install
command through the resolved runtime. Settings, the feature-wall panels and
the Linear prompt all wrap theirs as `wsl.exe -d <distro> -- sh -c ...` and
pass a matching shell override; onboarding spawned a bare terminal and handed
it the raw `npx skills add ...`. With Node inside WSL, npx is not on the
Windows PATH, so the install failed.
The runtime resolver had a second gap behind that: it only consulted
per-project settings, and onboarding runs before any project exists. With no
project it returned undefined and fell through to the Windows host, ignoring
a global WSL default entirely. `getLocalAgentPreflightContext` already had a
no-project fallback for PATH detection; the skill-install path had none.
- extract that fallback as `getGlobalWindowsExecutionRuntimeContext` and
rewire the existing agent-preflight branch through it so the two cannot drift
- adopt it in `useActiveProjectSkillRuntime` when no project is active. WSL
only: a windows-host default already matches the old no-project behavior,
and resolving it would hand skill discovery a target where it had none,
re-triggering scans for every host-default user
- build the onboarding terminal's command for the runtime and pass its shell
override
- register the CLI in WSL rather than on the host, so `orca` lands on the PATH
the install actually runs on, and wrap the copied command to match
* fix(onboarding): keep skill setup runtime consistent
* test(onboarding): satisfy runtime settings contract
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(terminal): route remote-runtime link clicks to the system browser
Terminal link clicks classified ownership from the global
activeRuntimeEnvironmentId, which is null when runtimes are bound per
workspace, so a link clicked in a remote-hosted pane opened a local-only
Orca browser tab and never reached the host. Thread each pane's resolved
runtimeEnvironmentId into openHttpLink as sourceOwner across the OSC 8,
WebLinksAddon, and click-fallback paths.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): route link clicks based on pane ownership, not global sta
Clicking links on remote-hosted panes was routing based on global runtime state, causing unexpected reconnections. Now link routing decisions (where to open: Orca vs system browser) are based on the actual pane's owner — local, SSH connection, remote runtime, or unknown — regardless of whether any runtime is globally active. This ensures a local pane can route to Orca while another pane's remote runtime is active, and a remote pane always routes to the system browser.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(browser): bound retained webview guests across worktree switches
* fix(browser): merge duplicate imports flagged by import/no-duplicates
* fix(browser): evict guests without unmounting the worktree surface
Unmounting the surface disposed every parked terminal byte watcher for the
evicted worktree (bell/title/agent-completion facts dropped for good after
the 15s handoff TTL) and skipped the force-park scrollback capture remote
panes rely on. Destroy the guests only: hidden slots mount no BrowserPane,
so nothing resurrects a destroyed guest before the next visit, and no slot
ever unmount-detaches a live guest (STA-3228). Terminal-state vetoes and
the eviction re-render bump are no longer needed; the only veto left is a
guest an automation/mobile controller is actively driving.
* fix(browser): veto eviction for downloading pages; keep zoom; add kill switch
Main cancels a page's active downloads when its guest unregisters (tab-close
semantics), so eviction now vetoes worktrees with a page still writing a
download, tracked app-wide because download state was pane-local and hidden
panes are unmounted. Eviction is not a user close: re-remember explicit zoom
past the destroy-path forget so a revisit reasserts the user's zoom instead
of resetting same-host siblings through Chromium's partition-wide HostZoomMap.
browserGuestWorktreeRetentionBudget mirrors the terminal budget kill switch.
* feat(crash-reports): add byte attribution to renderer memory highwater breadcrumbs
Entry counts stay flat when a slice grows by value weight (97b9e86d leaked
~700MB while its biggest slice grew by 4 entries), so highwater breadcrumbs
now carry sampled per-slice KB estimates for the store plus a live pane
census (managers, panes, estimated scrollback KB) — the dominant heap cost
the store census cannot see.
* fix(crash-reports): bound renderer OOM profiling
* fix(crash-reports): total raw store estimate bytes