Commit Graph
7942 Commits
Author SHA1 Message Date
Jinjing 7c4c021aa6 docs: update Android APK link to v0.0.37 (#12515) 2026-08-04 03:12:23 -07:00
github-actions[bot] 9058059154 Update README downloads badge 2026-08-04 09:51:50 +00:00
2548b816c0 Keep the app responsive when security software slows process creation (#12217)
* fix(ports): keep the app responsive when security software slows process creation

Orca ran the workspace port scan's probe commands (lsof/ps on macOS,
netstat + powershell.exe on Windows) directly in the Electron main process.
libuv performs process creation inline on the calling event loop, which in
the main process is the browser UI thread, so an endpoint-security module
hooking CreateProcessW froze the whole window for the length of the spawn.

The same stall also produced a false diagnosis: the 4s command watchdog was
armed before execFile (local-workspace-port-scanner.ts:389 -> :410), so its
deadline had already passed by the time the command started. Every scan on a
hooked host reported a command timeout, tripping the 60s -> 5min backoff and
the "Port scanning is temporarily paused after a command timeout" banner even
though the commands themselves were healthy.

Probe commands now run on a lazily created, unref'd worker thread with FIFO
one-at-a-time dispatch, and the watchdog is armed after execFile returns so it
measures the command rather than the spawn. Node's own execFile timeout kill
(killed: true) is classified as a command timeout, keeping the backoff working
for genuine hangs. A scan that observes a stalled spawn skips its optional
metadata commands for that cycle, capping a hooked-host scan at roughly one
stall instead of three.

Closes #11161

* fix(ports): keep advertised URLs when a stalled spawn skips port metadata

Review follow-up on #11161. The stalled-spawn early return handed
scanWorkspacePorts raw ports with no cwd/commandLine, so every port failed
attribution and reconcileAdvertisedUrls told the watcher each worktree's
listeners had vanished. shouldEvictAfterScan then deleted every cached
advertised URL and broadcast a removal event; those URLs are only ever
captured from live PTY output, so the dev-server link was gone until the
server restarted.

The scanners now report metadataAvailable, and reconciliation is skipped for
a scan that never gathered attribution evidence. The skip is also no longer
self-perpetuating: on an EDR-hooked host every spawn stalls, so gating purely
on the current scan's spawnMs made every port permanently external (Stop
refused with 'Only workspace-owned local processes can be stopped here.').
Metadata is now re-probed on the scan after a skip, matching what the comment
and test name already claimed.

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

* test(windows): stop a temp-dir lock from failing the CLI launcher smoke test

The native launcher assertions passed on windows-latest, but teardown's
rmSync raced Windows' release of the image handle on the exe the test had
just executed and threw EPERM, failing the job.

Cleanup now retries and, on Windows only, tolerates a residual lock code
instead of reporting it as a launcher regression.

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

* fix(ports): scope the metadata skip away from attribution-dependent scans

The metadata skip was a process-wide parity flag, so Stop and the
localhost-label allowlist could land on a degraded cycle and reject a
port the panel had just shown as workspace-owned. Give those callers an
explicit requireMetadata option, and carry the previous cycle's listener
metadata forward so a skipped background scan no longer republishes
workspace ports as external.

Also pin the watchdog ordering: the stall in the execution test was
shorter than the watchdog budget, so a watchdog armed before execFile
still passed.

* build: guard worker-thread entries against electron imports (#11161)

Electron's module is not registered on worker threads, so
require("electron") throws "Cannot find module 'electron'" inside a
main-process worker and kills it at startup (verified on Electron 43.1.0).
plain-node-entry-guard covered only forked plain-Node entries, so the five
worker entries relied on hand-written "must stay electron-free" comments.

The port-scan probe worker is one import away from
port-scan-command-client.ts, which deliberately contains require('electron').
A violation there fails closed at runtime while every unit test still passes,
because the client's require is try/caught on the main thread.

Covers stt-worker, warp-theme-parser-worker,
session-scanner-opencode-sqlite-worker-entry, main-thread-hang-watchdog-entry
and port-scan-command-worker-entry. The scan is transitive over the emitted
chunk graph, so a shared chunk that reaches electron is caught too.

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* test(windows): retry teardown for main's duplicate-PATH launcher fixture

Main's new csc-compiled harness runs an exe from the temp tree, which is
exactly the image-handle/AV lock the merged-in removeFixtureTree retry exists
for; its bare rmSync would report a teardown lock as a launcher failure.

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

* test(ports): pin the packaged-asar worker entry path

resolveWorkerEntryPath's packaged branch never runs in dev or e2e, so the path construction had no coverage. Split the electron read out of it and unit-test both layouts.

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

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 02:03:40 -07:00
Neil e1071f59e9 Say why an update install failed instead of stalling for three minutes (#12224)
Surfaces the real install-failure cause instead of letting a failed elevation stall silently, and keeps the reconnect wait inside its total budget by recomputing the remaining time after each awaited RPC.

Relates to #11906 — this fixes the observability half. The functional half (a .deb/.rpm host cannot elevate and can never self-update) is unchanged, so the issue stays open.
2026-08-04 02:02:53 -07:00
Jinwoo HongandOrcaWin e9cf106769 fix(relay): make fenceAndCloseNow stop the liveness safety net (#12482)
The 5-minute liveness tick from #12432 survived fenceAndCloseNow(), so a
tick landing between the pre-sign-out fence and the profile wipe could
briefly resurrect a broker (benign but soft — the entitlement check
bails afterward). The fence now clears the interval; the next auth
mutation re-arms it via refreshDemand, and the safety net otherwise
behaves identically. Found in release review of the #12432 cherry-pick.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 01:38:10 -07:00
Jinjing 00867f06e2 fix(ssh): handle owner displacement and graceful shutdown (#12367)
* fix(ssh): handle owner displacement and graceful shutdown

SSH connections can reconnect with valid session proof after network loss or
device sleep. When the incumbent owner is still half-open, allow the
reconnecting client to displace it outright rather than wait for socket closure
— a window that may never close. Retain displaced deliveries for the new owner
to rotate. During app shutdown, drain SSH sessions without terminating recovery
operations, and retry pending owner grants in case a replacement commits mid-drain.

* fix(ssh): handle owner displacement and graceful shutdown

Make QuitTeardownStartGate a shared singleton so SSH connects use the
same shutdown fence as the main quit path. Track test-connection probes
to ensure they complete before final teardown. Guard owner displacement
to prevent stale owners from clearing recovery state claimed by newer
owners.

* fix(ssh): fix flaky test sync and add error code safety check

Test was using tick-based Promise.resolve() loops which don't guarantee
the async operation has started. Replace with signal-based synchronization
that waits for the actual lease flush. Also add nullish-coalescing to
error code check to prevent crashes if error is null or undefined.

* fix(ssh): fence reset transport opens during shutdown

* fix(ssh): keep recovery leases stable across reconnects

* fix(ssh): close transports owned by cancelled connect attempts

When a connect is cancelled after its transport has opened, that cancelled
attempt still owns the transport and must close it — otherwise it leaks. Add
disconnectConnection() to close by identity (not by target ID) so a cancelled
attempt closes only the transport it minted, without tearing down its
replacement's live transport. Track priorConnection to detect whether this
attempt opened a new transport or reused an existing one, and close only on
abandonment if this attempt owns the session.

* fix(ssh): fence old owner proofs and close superseded transports

When an owner reconnects with a new proof while an old one is still live,
the old proof is now fenced with SUPERSEDED_ERROR instead of retrying
indefinitely. The relay also closes stale transports to signal that their
recovery generation has been overtaken by a newer one.

This ensures overlapping reconnect scenarios complete with the newest proof
rather than getting blocked by stale recovery attempts.

* fix(relay): re-pin stdin/stdout fds after closing to prevent recycling

When the relay closes stdin/stdout to signal EOF to the SSH peer, the OS
can recycle those fds (0 and 1) for new sockets or files. If Node still
treats process.stdin/stdout as those numbers, subsequent operations
corrupt socket clients and trigger shutdown errors. Re-pin the fds by
opening /dev/null to keep them occupied and prevent recycling.
2026-08-04 01:37:25 -07:00
Neil 8f29f58ab7 Keep showing your workspaces when a project is too slow to answer instead of reporting zero (#12222)
A stalled per-repo git scan no longer publishes a healthy-looking empty catalog. Adds an execution-host ownership gate so a degraded host cannot republish another host's worktree rows under its own id.

Relates to #11869 — this fixes the stall-publishes-zero half. The issue stays open for the remainder.
2026-08-04 01:26:52 -07:00
NeilandOrca 96e31e7bf8 Remove a paired computer's deleted projects from every connected device (#12215)
* fix(repos): remove a paired computer's deleted projects from every connected device

A project deleted on a paired Orca host stayed in every connected client's
sidebar and could not be removed there.

Two independent defects:

1. Host-local repo IPC mutations only sent `repos:changed` to the host's own
   renderer (src/main/ipc/repos.ts:2711). The runtime client-event stream was
   fed only by mutations arriving over runtime RPC, and clients refetch a remote
   catalog only on a `reposChanged` event -- there is no polling on desktop -- so
   the deleted rows persisted indefinitely. The shared `notifyReposChanged`
   helper now also calls the new
   `OrcaRuntimeService.notifyReposChangedForRemoteClients()`
   (src/main/runtime/orca-runtime.ts:5175), mirroring the existing
   `notifyWorktreesChangedForRemoteClients` precedent. This covers every repo,
   project-group and folder-workspace IPC mutation, so renames, colors, reorders
   and adds propagate too.

2. Deleting the ghost row on the client routed `repo.rm` to the owner, which
   answered `repo_not_found`. `removeProject` wrapped its whole body in one
   try/catch, so the rejection aborted the local purge before the `set()`
   (src/renderer/src/store/slices/repos.ts:3466) and the delete button appeared
   to do nothing. Only `repo_not_found` is now tolerated; any other failure still
   keeps the row, and an opt-in `errorFeedback: 'toast'` makes it visible at the
   three single-project user-initiated entry points. Bulk and background callers
   keep today's silence plus their own aggregate reporting.

Closes #11994

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

* fix(repos): revert inert RepositoryPane removeProject arg

The settings pane's only render site drops the argument; the toast is
already delivered by removeSettingsProjectFromAllHosts.

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

* fix(repos): scope duplicate-repo-id deletes to the owning execution host

Cover the cross-host collisions #11994's broadcast now fans out to every paired
device. Same-name projects on different hosts were already isolated (per-host
UUIDs, host-scoped catalog merge and purge) and are pinned by regression tests.

Two same-repo-id paths were not: `repo.rm` with a `path:`/`name:` selector and
`deleteProjectHostSetup` both resolved one row and then deleted by bare id,
taking the sibling host's registration with it.

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

* test(mobile): align the poll-interval rationale with the new reposChanged emission

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

* fix(repos): resolve deleteProjectHostSetup's repo row only on the setup's own host

The sibling-host fallback could only ever pick a row on a host the caller
did not name; with no exact match the setup is stale and the existing path
already drops just the setup.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:26:48 -07:00
NeilandOrca 5f9a5b80b3 Show "Except default branch" filter only when "Hide sleeping" is on (#12460)
* Show the "Except default branch" filter only when "Hide sleeping" is on

The exemption only takes effect during the hide-sleeping sweep, so its row
now renders (and counts toward the filter badge) only while its parent
toggle is ticked.

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

* Restore the sub-option indent on filter toggle rows

Tailwind v4 emits px-2 as padding-inline, which outranks the physical pl-6
override, so the indented row rendered flush with its siblings. Split the
base padding so the indent actually applies.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:22:34 -07:00
NeilandOrca b86880a9c1 Keep finished and interrupted agent sessions resumable after sleeping a workspace (#12214)
* fix(agent-sleep): keep finished and interrupted agent sessions resumable after sleeping a workspace

Manual workspace sleep ran a liveness filter over the panes it was about to kill
(isValidManualSleepLiveAgentEntry), then wiped every pre-existing sleeping record
in the worktree. A done, interrupted, typed-into, or >30-min-idle pane therefore
lost its only resume handle and woke as a bare shell. A second filter at wake
discarded any record carrying `interrupted: true`, which also killed interrupted
sessions across an app restart.

Capture now records every resumable pane, normalizing only `updatedAt` and
`interrupted` and preserving the entry's real `state` so a finished pane keeps
its passive record and resumes in place when its tab is opened instead of
spawning a duplicate tab. The wake-side interrupted check is gone. A
legacy-orchestration-worker block is carried onto the replacement record, and
the Pi-compatible promoted checkpoint is no longer overwritten by a re-derived
live record.

Closes #11598

* fix(agent-sleep): keep durable slept records a repeat sleep cannot re-derive

removeSleepingRecordsReplacedByManualWorktreeSleep wiped every record in the
worktree, and only the freshly captured set was merged back. A slept `done`
pane stays passive until its tab is opened, so a second sleep found no live
status row to rebuild its record from and deleted the pane's only
`--resume` handle with nothing written back — the original loss of #11598,
one wake/sleep cycle later.

The wipe now skips a record with no replacement in the new capture set when
it is a durable capture (`origin` `worktree-sleep` or `quit`). Provisional
`live`/legacy checkpoints are still cleared, so an unresumable Pi row does
not survive a sleep it cannot back.

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

* fix(agent-sleep): keep a slept workspace's finished panes out of the mobile wake fan-out

A manual sleep now records every finished pane, and those passive records fed
wakeSleepingAgentsForWorktreeInBackground step (b), which background-mounts one
tab per passive record. A phone opening a slept 12-tab workspace would have
cold-restored 12 agents at once, undoing the process shedding the sleep was for.

Manual-sleep captures of finished panes carry restoreOnTabOpenOnly; step (b)
skips them and the pane resumes in place when its own tab is opened, which is
what desktop activation already did and what the phone's per-tab mount provides.

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

* fix(agent-sleep): close the retained-row and shared-claim gaps in slept-session capture

Three narrow holes left by the manual-sleep capture rewrite, all in the same
"a slept session must stay resumable" contract:

- The retained pass captured `retained.entry` verbatim, so a retained row -
  stale by construction, since it exists only after the pane's pty died -
  produced a `working` record that wake then discarded on the >30min staleness
  rule. It now takes the same `updatedAt`/`interrupted` normalization and the
  same `automaticResumeBlockedBy` carry-over as the live pass.
- The retained pass also ran after the `origin: 'live'` promotion loop without
  its guard, so a pane holding both a promoted checkpoint and a retained row
  had the checkpoint (connectionId, transcript identity, active `working`
  class) overwritten by a re-derived passive record.
- The mobile wake filtered `restoreOnTabOpenOnly` records *after*
  canonicalization, so a lazy record sharing a provider-session claim with an
  eligible hibernated record could win the claim, delete the hibernated record
  as a duplicate, and then be skipped - stranding the session with nothing
  mounted.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:01:19 -07:00
f6878d660f Show Claude's AskUserQuestion card in desktop Chat when the agent runs on a paired headless server (#12223)
* fix(native-chat): show Claude's AskUserQuestion card when the agent runs on a paired headless host

Three gaps kept the question card off the desktop when the agent ran on a
remote `orca serve` host:

- The `session.tabs` projection reduced HTTP agent-hook rows to identity only,
  hard-coding `state: 'done'` and an empty prompt, so `toolName` and the full
  `interactivePrompt` never left the host. It now publishes the newest fresh
  hook row's status fields, bounded by the same staleness window `agentType`
  uses, excluding `providerSessionOnly` resume rows, and yielding to live
  title evidence unless a question is actually pending.
- Nothing republished `session.tabs` when only a hook row changed, and the
  re-emit carried an unchanged `snapshotVersion` that clients drop on their
  monotonic gate. Material hook transitions and pane/SSH status clears now
  bump the version and schedule a coalesced emit.
- The desktop card resolved only from live status. It now falls back to the
  pending ask in the transcript, matching mobile, so a relay gap can no longer
  leave the composer mounted over a pane parked on a selector.

Closes #11761

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

* fix(native-chat): date the hook-row recency guard against a real clock

`resolveHookLiveAgentRow` compared a hook `receivedAt` (epoch ms) against
title stamps that are title-observation sequence numbers, so the guard could
never fire — any fresh hook row overrode live title-derived state, and a manual
rename (the one epoch writer) inverted it. Stamp the live OSC title path with
wall-clock ms and compare against that alone.

The regression test fabricated epoch-valued title stamps production never
writes; it now drives the title through `onPtyData`, and a new case pins the
opposite direction (hook row newer than the title wins).

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

* fix(native-chat): stop an orphaned tool call from pinning a dead question card

extractPendingAsk pairs tool results to calls by a global FIFO (tool_use_id
is dropped at decode time), so one call that never gets a result desyncs the
queue for the rest of the transcript and strands an answered ask as pending.
Real transcripts also hold asks the user escaped and typed past. On desktop
that card replaces the composer, so the pane became unsendable.

Drop in-flight calls at a turn boundary — a user turn or the decoders'
interrupt row — since the turn that owned them is over. Claude's tool-result
turns decode as role 'tool', so normal FIFO resolution is untouched.

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

* refactor(native-chat): trim the headless AskUserQuestion projection

Reuse rather than restate: the invalidator now takes the shared
`AgentHookEventPayload` instead of a locally redeclared row shape, and the
hook live row is a `Pick<>` of the retained OSC snapshot so one projection
branch consumes either carrier. Fold the immediate/coalesced session-tabs
emit into one method (also drops a redundant re-emit on the
provider-session push). Drop card tests that re-route shared-parser
assertions through React. Isolate pane-status-clear subscribers and prove
the no-republish case by version arithmetic instead of a timed silence.

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

* test(native-chat): pin the AskUserQuestion card render under real Electron

Why: the 13 parser unit tests pin extraction, but nothing proved a card
actually renders where an inert tool call used to. This spec reproduces the
paired-headless topology from the client side — live status carrying agent
identity and state 'working' but no interactivePrompt/toolName, with the
pending ask present only in the transcript — and fails on main.

Refs #11761

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

* test(native-chat): drop the unused testInfo parameter

Why: oxlint no-unused-vars fails the lint gate on an unused test parameter.

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

* test(native-chat): drop leftover proof scaffolding from the ask-card spec

The env-var screenshot label and the fixed 2s settle only existed to make
the pre-fix capture comparable; the card assertion already waits.

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

* test(runtime): use a truly unresolvable pane key in the hook republish guard

#11203 taught pane lookup to recover a reminted tab id by leaf id, so the old
fixture (new tab id, live leaf id) resolved and bumped the snapshot a second
time once this branch merged with main.

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

* fix(runtime): refuse a hydrated unconfirmed hook row as live pane status

#12346 landed on main after this branch was cut: a nonterminal row restored from
last-status.json is stamped `restoredUnconfirmed` because its transition may have
fired while no receiver was up, and every freshness gate treats it as never-fresh.
The new headless `live` projection here only checked `receivedAt`, so a restart
inside the 30-minute window would republish the hydrated row — resurrecting the
AskUserQuestion card with no agent left to answer it.

`agentType` still reads those rows: they prove identity, just not liveness.

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

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Neil <nwparker@users.noreply.github.com>
2026-08-04 00:48:58 -07:00
Brennan Benson c498d2d405 perf(renderer): index detectedWorktreesByRepo so SSH panes and cards stop walking the full detected catalog on every store write (#12421)
* perf(renderer): index detected worktrees for owner lookups

* test(renderer): make detected index perf gate deterministic

* test(renderer): cover detected index owner hot path
2026-08-04 00:26:40 -07:00
Brennan Benson 9f638da62d perf(terminal): gate mobile overlay ticks by pane pty affinity (#12419)
The fit-override and driver emitters are global listener sets: every event
reaches every mounted TerminalPane. Both tick callbacks re-rendered the tab
before checking whether the event's pty belonged to it — the override branch
only used its affinity filter to guard the rAF refit, and the driver branch had
no filter at all.

Remote handle rotation (replaceFitOverridePtyId / replaceDriverPtyId on
resubscribe after a network blip) republishes state for every reconnecting
pane, so the cost was quadratic in mounted pane count and remote-exclusive.

Move the pty-affinity check ahead of both ticks, matching the guard shape
already used by use-native-chat-can-send and pty-connection. The subscription
pair is extracted into useMobileOverlayTicks so the fan-out is directly
measurable under a real React render.
2026-08-04 00:20:10 -07:00
NeilandOrca 29bfc1e22c Fix Windows git/gh attribution wrappers rejecting a piped stdin argument (#12440)
Co-authored-by: Orca <help@stably.ai>
2026-08-03 23:49:19 -07:00
OrcaWinandOrcaWin ac7f9a4fe1 fix(pty): require proven absence before retiring a stable pane owner (#12393)
* 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>
2026-08-03 23:48:18 -07:00
NeilandOrca d8e5944b60 Stop a duplicate headless orca serve from crash-looping and exhausting AppImage FUSE mounts (#12212)
* fix(startup): stop a duplicate headless serve from crash-looping and leaking AppImage mounts

A second Orca launch that loses the single-instance lock called app.quit()
before `ready`. That quit is deferred, so the doomed process kept booting into
Chromium's Linux display initialization, failed with "Missing X server or
$DISPLAY", and died with SIGSEGV. systemd read that as a crash and restarted it
forever; each restart re-mounted the AppImage and left the squashfuse mount
behind, until the host hit the 1000-mount FUSE ceiling and every later launch
failed.

The lock-losing launch now calls app.exit(3), which terminates synchronously
before any display init. Exit code 3 is a stable "another process already owns
this userData profile" contract, and the documented systemd unit uses
RestartPreventExitStatus=3 plus a real StartLimitIntervalSec/StartLimitBurst
window so a permanently failing launch can no longer retry unbounded.

Second-instance argv is now forwarded to the owner, and a duplicate `orca serve`
no longer asks the live headless server to open a desktop window. Desktop
activation for ordinary launches and macOS dock re-activation is unchanged.

Closes #11935

* docs(headless): clear the start limit before the scripted service starts

StartLimitIntervalSec=300/StartLimitBurst=5 rate-limits operator starts too, so
after a crash-loop trips the burst systemd refuses a plain `systemctl start` for
the rest of the window. The Upgrade and Roll back scripts run under
`set -euo pipefail`, so that refusal aborted the rollback mid-flight and left the
server down on the exact recovery path the doc prescribes.

Both scripts (and their EXIT-trap recoveries) now run `systemctl reset-failed`
first, the unit reference explains the interaction, and the crash-loop bullet
points at it for manual starts.

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

* test(startup): reproduce the #11935 duplicate-serve crash loop under real Electron

The committed coverage for #11935 was source-text greps, so nothing gated the
mechanism the fix rests on: pre-`ready` `app.quit()` is deferred, which is why
the lock-losing headless `orca serve` kept booting into Linux display init.

This runs two real Electron processes against one disposable profile. The
duplicate executes the lock-loss gate's own `app.*` statement, lifted out of
`src/main/index.ts`, so reverting to `app.quit()` fails the test. It also feeds
the owner's real forwarded argv through `shouldActivateDesktopForSecondInstance`.

Also record why the activation predicate matches `--serve` and not the `serve`
subcommand: an AppImage launched as `orca serve` exits at the CLI redirect
before requesting the lock.

* test(startup): wait for the owner process to exit before removing its profile

Windows holds the profile's handles for a beat after SIGKILL, so an immediate
rmSync can fail with EBUSY/EPERM.

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

* test(startup): pass the fixture marker path by env, not argv

Chromium reorders argv and the duplicate's argv is itself under test, so a
trailing positional was the wrong channel for it.

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

* test(startup): only the activation case waits on the owner notification

The exit-contract cases assert on the duplicate's own already-terminated
process, so they should not block on cross-process delivery.

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

* test(startup): drop the staged lock race, keep the real-Electron gate contract

CI proved the two-process form cannot work on a display-less Linux runner:
Chromium's ProcessSingleton needs the browser IO thread, which needs `ready`,
which needs a display. The pre-`ready` owner looked stale and the duplicate took
the lock (`expected [ 'DUPLICATE_WON_LOCK' ] to include 'DUPLICATE_LOST_LOCK'`).

Lock acquisition and argv forwarding are already covered in
single-instance-lock.test.ts. What only a real process can settle is what the
loser does next, so that is all this file now runs -- display-independent.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 23:34:22 -07:00
OrcaWinandOrcaWin e599d924bd fix(editor): stop Close All / Close Others freezing the renderer on large tab sets (#12404)
* 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>
2026-08-03 23:27:28 -07:00
OrcaWinandOrcaWin fb1259a09d fix(mobile): keep cached workspace counts across a transient RPC failure (#12408)
* 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>
2026-08-03 23:26: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
OrcaWinandOrcaWin 5a2b329d8d chore(mobile): bump to 0.0.37 (versionCode 10) (#12365)
Completes the 0.0.37 release attempted on 2026-08-03 (run 30791649691
failed on the version assertion). Ships the post-0.0.36 transport fixes:
relay session recovery when the LAN endpoint is unreachable (#12344,
#11368, #11465, #11690) and honest worktree-catalog failure states
(#12235) — the released-app defect class verified live tonight.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
mobile-android-v0.0.37
2026-08-03 23:24:25 -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
OrcaWinandOrcaWin e8d3043107 fix(mobile): stop unrenewed-grace rotation churn and gate cadence gaps (#12426)
- skip proactive rotation when the resume confirmation reports renewed=false
  (a re-resume provably returns the same unchanged deadline; rotating churned
  one session replacement per clamp floor, ~60/hour, until a fresh credential)
- armCredentialReprobe under a held gate mints the tick's pass token so the
  effective reprobe cadence stays 60s..15min instead of doubling to ~30min
- registerFailure honors scheduleRetry=false in gate branches: no reprobe
  timer is armed while backgrounded/stopped; foreground resume re-arms
- extract RelayRetryDelays and supervisor test fakes into their own modules
  (max-lines)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:23:33 -07:00
NeilandOrca e8d4818b5a test(env): keep happy-dom MutationObserver callbacks alive across GC (#12438)
happy-dom holds each observer's internal callback in a WeakRef, so a GC pause
under sharded CI load permanently kills a still-connected observer. That is what
made the activity portal readiness test flake: once the observer died, no amount
of re-applying ready DOM could release the latch.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 23:18:15 -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
OrcaWinandOrcaWin 9ba293cb74 fix(mobile): keep relay runtime recovery alive without direct connectivity (#12374)
* fix(mobile): keep relay runtime recovery alive without direct connectivity

A phone paired over the relay whose direct LAN endpoint is unreachable
(e.g. a Tailscale IP with Tailscale off) could lose the runtime channel
permanently: the reconnect controller's recovery gates parked with no
timer and no logs, the supervisor snapshotted relay credentials once at
start (dying silently if the read failed and dialing stale tokens after
rotation), and the only path that cleared a rejected-credential gate
required a working direct connection. Field symptom: home card shows
"Connected - Orca Relay" (or "Can't connect - check Tailscale") while
the host page sits at zero worktrees forever.

- gates (fresh-credential, external-signal) now arm a slow 60s reprobe
  instead of parking; each gated attempt re-reads the durable credential
  bundle and adopts it when its version is fresher than the rejected one
- supervisor start no longer dies for the process lifetime when the
  initial Keychain read fails or the bundle is expired
- every recovery decision now reaches logcat and the in-app connection
  log ([relay] lines); previously the whole relay dial path was silent
- direct-return probing extracted to mobile-direct-return-probe.ts,
  credential selection to mobile-relay-credential-selection.ts

Regression suite mirrors the field failure (rejected outer credential,
unreadable bundle at start, expired bundle, E2EE rejection without a UI
nudge) plus real-rpc-client failover integration tests; the four
deterministic scenarios fail on the previous code.

* fix(mobile): adopt durable relay credentials by outcome, not version

Adversarial review caught two blockers in the version-comparison rule:
renewals extend expiresAt without bumping current.version, and a re-pair
restarts the version counter — both left the durable bundle unadopted
and reproduced the original outage. Selection now adopts the disk bundle
exactly when it yields a dialable (unexpired, non-rejected) credential
while memory does not, which also keeps revoked versions unresurrectable.

Also from review: the gate reprobe cadence now escalates 60s -> 15min
ceiling with 0.75-1.25x jitter (no fleet phase-alignment, no permanent
one-minute beacon); clearing a gate drops its timer, pending tick, and
cadence so an orphaned reprobe cannot swallow the next fast backoff; the
reprobe tick token is only minted while its gate still holds; and a
merely missing/expired bundle uses a plain cooldown instead of the
fresh-credential gate so it cannot force rotations on direct reconnects.

New regression tests (all red on the previous code): renewal without a
version bump, re-pair with a restarted counter, orphaned-timer backoff
swallowing, escalating gated cadence, and background/foreground recovery
after an E2EE rejection.

* fix(mobile): reset gated relay cadence on app resume

Review round 2: an escalated fresh-credential gate kept its cadence
across background/foreground, so reopening the app could wait out a
15-minute tick (measured 11.25min to first attempt after a 2h
background) — indistinguishable from the outage itself. A resume now
resets the streak even when it cannot lift the credential gate, and a
successful direct connection does the same in resetForDirectConnection.

Also: the streak now advances once per fired tick instead of once per
armed-delay computation (three arms per cycle escalated 60s -> ceiling
in ~7 minutes instead of the documented eight steps); delay computation
is a pure read.

* fix(mobile): rotate relay sessions on resume expiry, not attach deadline

Live phone verification of the failover fix exposed a second defect the
old latch had been masking: the relay-hello's leaseExpiresAt is the
cell's attach-reservation deadline (now + 10s for resumes,
credential-store.ts:213 server-side), but the supervisor scheduled
proactive rotation from it with a 30s margin clamped to 1s — so every
relay runtime session force-replaced itself ~1s after connecting
(measured every ~2.5s on device, 253 dials per 5 simulated minutes in
the red test). Any RPC slower than the cycle could never complete,
which is the "Worktree list unavailable" symptom.

The session now captures resumeExpiresAt from the hello (updated by the
resume confirmation) and rotation keys off it. Test fakes previously
used a 120s lease, which is why no suite ever reproduced the loop; they
now mirror the production 10s attach deadline, and a churn regression
holds one session across 5 minutes with direct unreachable.

* fix(mobile): clamp lease rotation delay on both ends

Adversarial review of the resume-expiry rotation fix caught an int32
setTimeout overflow: production resumeTtlMs is 30 days, and
30d - 30s = 2,591,970,000ms exceeds INT32_MAX, so Node (and vitest's
fake timers) clamp the timer to 1ms — 3001 relay dials and credential
writes in 3 simulated seconds, ~2500x worse than the churn being fixed.
The delay is now clamped to [60s, 6h]: the ceiling makes overflow
unreachable regardless of server TTL (a harmless re-resume every 6h on
long sessions), and the floor bounds any bad deadline to one forced
rotation per minute instead of a sub-second loop — which also disarms
the Math.max(1000, ...) landmine for return-unchanged-grace resumes
whose stored expiry can be arbitrarily near.

Also from review: getLeaseExpiresAt is renamed getAttachDeadlineAt (it
had zero production callers left; the plausible name is how the churn
bug happened), the expired-vs-missing bundle cases now log distinct
strings, and both test fakes use production constants (10s attach
deadline, 30-day resume TTL) — fictional fake values hid all three
defects in this subsystem. The four forced-rotation lease tests are
retimed to the 60s floor with direct pinned unreachable so return
probes cannot race their windows.

* style(mobile): merge duplicate imports in relay failover test

* style(mobile): use T[] array syntax in credential selection

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 21:37:29 -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
Brennan Benson 9a97e737f5 test(e2e): pick the board marquee start point from live geometry (#12409)
* test(e2e): start the board marquee off the board's padding cliff

`selects the full lane across a single large marquee scroll jump` failed the
changed-e2e-specs job with `Received: 0` — no card ever previewed. The CI
trace shows the drag started at (284, 195) and the failure screenshot shows a
native text selection with no marquee overlay, so the board never accepted
the pointerdown.

An element scan across that row shows why: the marquee may only start on
empty board space, and the usable strip is only x 280-298 — the board's own
left edge on one side, the first lane's cards on the other. `selectionBox.x +
4` aims 4px inside that strip, so a layout that rounds a few pixels
differently lands outside the board entirely, where the pointerdown never
reaches the handler and the browser text-selects instead.

Aim at the middle of the measured strip, assert the start point really is
empty board space before pressing, and assert the selection rect appears so a
rejected gesture fails immediately instead of surfacing 15s later as "0 cards
previewed".

Also carried over from the first attempt at this deflake: the lane scroll now
jumps until the virtualizer stops moving the bottom, because a measured card
is much taller than the row estimate and a fixed pass budget commits the
marquee short of the last cards; and the final assertion checks the badge's
text so a short selection reports its count.

* test(e2e): pick the marquee start point from live geometry, not a precomputed one

Round 2 fired the new guard: `marquee start point (290, 195) must be empty
board space` with `onSurface: false` AND `onIgnoredTarget: false` — so
elementFromPoint returned something outside the board entirely, even though
(290, 195) sits inside the measured 280..299 strip. Aiming at the middle of
the strip is still aiming at a point computed before the probe runs, and the
sheet, sidebar and lane fill keep resizing that strip afterwards.

Stop precomputing the point. Read the geometry and scan for an empty point in
the same DOM turn, walking a grid across the strip between the board's left
edge and the first card, over the lane's top rows only — the marquee anchors
its range in content space, so a start below the first card would drop it from
the 102. Take the first point the board itself reports as empty, and require
two consecutive probes to agree so a frame mid-relayout cannot win.

Keep the precondition guard, now reporting the live geometry and the elements
that blocked each rejected point. If no empty point exists at all, skip with
that reason instead of failing on a layout the test cannot drive.

* test(e2e): start marquee from an explicit empty lane

* test(e2e): find visible board space for marquee start

* test(e2e): quarantine unstable marquee hit test
2026-08-03 20:58:25 -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
Shahar MorandOrcaWin 026ed921c1 fix(mobile): keep main-buffer TUI footer above the iOS keyboard (#9178)
* fix(mobile): keep main-buffer TUI footer above the iOS keyboard

The iOS keyboard-avoidance lift anchored on the terminal cursor row. Pi's
TUI renders in the main screen buffer (not the alternate screen) with its
footer/status rows below the input caret, so the altScreen full-lift branch
was skipped and those rows stayed under the raised dock / keyboard.

Anchor the lift on the bottom-most non-blank viewport row instead of just
the cursor: the WebView now emits contentBottomRow, and the lift uses
max(cursorY, contentBottomRow). This generalizes the alt-screen case,
keeps short output at the top put, and matches prior behavior for a
scrolled shell prompt.

Extracted the lift into a pure, unit-tested function
(terminal-keyboard-avoidance-lift.ts) and moved metrics parsing into a
tested helper on the contract.

* fix(mobile): preserve keyboard metrics through notification dispatch

* fix(mobile): harden terminal keyboard metrics

* fix(mobile): ignore unstyled terminal whitespace

* fix(mobile): preserve decorated terminal whitespace

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 20:25:46 -07:00
a7ed5a45c2 fix(mobile): render Mermaid diagrams in MobileMarkdown (#11185)
* fix(mobile): render Mermaid diagrams in MobileMarkdown (#11141)

Co-Authored-By: Grok Companion <noreply@x.ai>

* fix(mobile): keep streaming mermaid fences as raw code until the fence closes

* perf(mobile): memoize MermaidDiagram and add a CDN load watchdog

* fix(mobile): escape mermaid source before embedding in WebView script

JSON.stringify leaves </script>, &, and U+2028/U+2029 raw, so a diagram
source containing </script> broke out of the inline script and ran
arbitrary WebView JS. Diagram source is untrusted (agent output, PR/chat
content), and this component now renders from chat and markdown preview,
not just the PR sidebar. Escape those chars to \uXXXX; the literal still
parses back to the exact source. Adds an adversarial buildHtml test.

* fix(mobile): embed the mermaid engine instead of fetching it from a CDN

The diagram WebView loaded mermaid from jsdelivr at runtime: offline and
constrained-network renders always fell back, the stalled-load watchdog
existed only to paper over that, and an unpinned floating-major CDN script
with no integrity check ran inside the WebView. Embed the lockfile-pinned
package's prebuilt bundle via a postinstall generator (same mechanism as
the terminal WebView engine) so the document loads nothing external; the
watchdog is removed as obsolete and a no-external-URL gate pins it.

* chore(deps): align mermaid at 11.16.0 across desktop and mobile

Desktop floated ^11.15.0 while the mobile embedded engine resolved 11.16.0.
Raise the desktop floor so both lockfiles resolve the same version, and pin
mobile exact: the generated WebView engine embeds the package bytes, so an
implicit range bump would silently change what ships.

* fix(mobile): block Mermaid diagram network requests

Mermaid image-node URLs can initiate subresource requests even with the engine embedded. Keep the WebView offline by restricting resource types through its document CSP.

* style(mobile): format Mermaid routing test

* fix(mobile): use stable keys for Mermaid diagrams

* fix(mobile): keep duplicate Mermaid keys distinct

Combine each diagram source with its sibling occurrence so identical diagrams remain unique while source edits still remount the WebView and later streaming prose does not.

* fix(mobile): keep Mermaid transitive within release-age policy

---------

Co-authored-by: Grok Companion <noreply@x.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-03 20:05:16 -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