Commit Graph
5569 Commits
Author SHA1 Message Date
JinjingandOrca 81dfa1db55 feat(gpu): add the GPU fallback ladder, cache purge, and driver-identity modules
`--disable-gpu` does not stop Chromium from spawning a GPU child for the Viz
display compositor, so a driver that CHECK-crashes at GPU init keeps killing
every launch even with the fallback applied. These are the pieces a real
remediation needs, added standalone with no callers yet:

- a bounded, additive tier ladder (disable-gpu -> +compositing/ANGLE swiftshader
  -> +in-process-gpu, which removes the crashing child entirely)
- a GPU/shader cache purge, so a cache written by the crashing driver is not
  replayed into the tier we just escalated to
- a summarizer for app.getGPUInfo('complete'); nothing in src/main called it, so
  every GPU crash bundle arrived with no vendor, device, or driver version

Co-authored-by: Orca <help@stably.ai>
2026-07-25 12:15:00 -07:00
Brennan Benson 505967eba0 fix(runtime): report the effective ask timeout so a clamped wait isn't misreported (#10550)
The 30-min clamp was silent: the ask result carried no timeout figure, so
the CLI printed the value the caller *sent*. A worker passing
--timeout-ms 3600000 was told "ask timeout after 3600000ms" after only
30 min of real waiting — off by 2x, and accurate before this PR added the
clamp. Echo the effective budget on every ask return and print that.

Additive optional field; older clients fall back to the requested value.
2026-07-25 05:42:05 -07:00
Brennan Benson b31e9bb03d fix(worktree): bound the .worktreeinclude copy so a huge include can't freeze workspace creation (#10540)
* fix(worktree): bound the .worktreeinclude copy so a huge include can't freeze creation

`.worktreeinclude` copying was bounded in entry count (1000) but unbounded in
bytes and files, and awaited inline during worktree creation. A repo listing
`node_modules` froze creation for minutes behind the create dialog on Linux and
Windows, where the fallback is a full `fs.cp` (macOS gets a cheap APFS clone).

Measure each copy-mode source against a cumulative budget (2 GB / 50k files)
before the first byte is written, and refuse the entries that bust it. Refused
entries ride the existing `CreateWorktreeResult.warning` channel so a workspace
never silently comes up missing its included files.

Pre-measurement rather than mid-copy abort: `fs.cp` ignores its `signal`
option, so a started copy cannot be cancelled and would strand a partial tree.
Refusing up front means there is no partial state to clean up.

* fix(worktree): don't charge bytes for copy-on-write clones, and bound the sizing walk

Two defects in the copy budget, both found by review:

- The byte limit was applied on macOS, where the copy is an APFS clonefile.
  Measured: a 2.7 GB tree clones in 22 ms and consumes no disk. Refusing it on
  a 2 GB byte ceiling denied work that was already free — a regression on the
  one platform this bound was never meant to touch. Bytes are now charged only
  when a byte-for-byte copy will actually run; the volume probe that decides
  this is the same cached df+diskutil pair the clone runs, and writes nothing,
  so the "refuse before the first byte" invariant holds. The entry limit still
  applies everywhere: inodes are real work even on the clone path.

- A refused entry consumed no budget, so a `.worktreeinclude` listing many
  over-budget directories paid a fresh full-limit walk for each one — up to
  1000 x 50,000 lstat calls, re-creating the stall this bounds. The walk is now
  charged against its own ceiling whatever the verdict.

Also documents that `admit()` must be awaited sequentially (CodeRabbit).

* fix(worktree): give the sizing walk headroom so one huge entry can't starve the rest

The walk ceiling added in the previous commit was seeded with maxEntries, the
same number the entry limit uses. Sizing an entry that busts the file-count
limit walks maxEntries + 1, driving the ceiling negative, so every later
`.worktreeinclude` entry was refused without being measured at all.

That regressed the common case: a repo listing `node_modules` plus `.env` used
to get `.env`; it silently got nothing. Reproduced, and now covered by a test
that fails when the headroom is removed.

The walk now gets 5x the entry budget, so total sizing work stays bounded
(<=250k lstat per materialization, vs the 1000 x 50k this ceiling exists to
prevent) while ordinary lists never reach it. Entries refused because earlier
ones exhausted the walk report a distinct 'sizing' reason, so the warning stops
quoting size limits at a 4-byte file that was never measured.

* fix(worktree): bill a failed clone's bytes, and blame the right ceiling

Two follow-on defects from the copy-on-write fix:

- A predicted APFS clone that then failed mid-copy (EPERM, ENOSPC) fell through
  to a real `fs.cp` whose bytes were never charged, because the entry had been
  admitted on the premise that cloning is free. That reopened the unbounded
  copy on macOS. The measured size is already known, so the fallback now bills
  it and refuses if it no longer fits, reporting the entry as skipped instead
  of silently copying gigabytes. A clone that was never viable
  (ApfsCloneUnavailableError) was already charged as a real copy, so that path
  keeps falling back as before.

- The walk ceiling is also applied inside the measurement via
  min(remainingEntries, remainingWalk), and when the walk term bound, the
  refusal was still reported as 'entries' — telling the user a 3-file directory
  busted a 4-file limit. It now attributes to whichever ceiling actually bound.

Also fixes the singular warning text, which said "entry X was not copied ...
copying them would exceed ... Copy them in manually".

* fix(worktree): flag a partial clone leftover, cap the warning, cover two branches

- A clone that fails partway only removes an *empty* reservation, so leftovers
  can survive at the target. Reporting that entry as simply "not copied" sent
  the user to copy it in manually, straight into a half-populated directory.
  Those skips now carry mayBePartial and the warning says to check the path
  first. Cleaning up the leftovers stays the deferred follow-up it already was.

- The warning enumerated every skipped path. `.worktreeinclude` allows 1000
  entries and all of them can be skipped, so it now names five and counts the
  rest — an unbounded string is a poor look in a PR about bounds.

- Two load-bearing branches had no test, both proven by surviving mutants: the
  `bytesAreCopied` short-circuit (reachable when a wedged df/diskutil makes the
  volume probe answer "no clone", so bytes are charged up front and must not be
  billed twice), and chargeBytes actually consuming budget for later entries.

* fix(worktree): only flag directory clones as partial, and cap that list too

- mayBePartial was set for every refused clone fallback, but only a *directory*
  clone can leave anything behind: the file path clones into a temp name and
  publishes with link(2), so a failure leaves nothing at the target. Sending
  the user to inspect a path that does not exist is its own small lie.

- The partial-copy sentence sliced to five names without the "and N more" that
  the other sentence appends, so entries past the fifth were surfaced nowhere.
  Both sentences now share one nameList helper.
2026-07-25 05:11:03 -07:00
Brennan Benson 159057c5d4 test(git): cover the false-positive class the header fix also removes (#10547)
The old anchored regex matched neither branch on a `[section "sub"]key = value`
line, so the parser never left `[core]` and credited the next indented line to
it — reporting sparse for a worktree git says is not. Fails on the pre-fix
parser (returns true where git reports unset).
2026-07-25 04:36:40 -07:00
Brennan Benson baf25785e0 fix(tasks): keep repos with a pending remote-identity probe in the picker (#10527)
* fix(tasks): keep repos with a pending remote-identity probe in the picker

Task-repo eligibility filtered on `hasProjectRemoteIdentity`, which is
populated by a background `git remote -v` probe. When the probe could not
reach git — an SSH-hosted repo whose connection is not up yet, a cold
launch — the repo silently vanished from the Tasks picker and stayed
hidden for the full 5-minute negative-cache TTL, even after the host came
back. GitHub repos were largely shielded because a persisted `upstream`
satisfies the identity projection through a different route; GitLab and
other providers depend on the probe.

Distinguish unknown from settled instead of hiding both:

- `probeGitRemoteIdentity` reports `resolved` / `no-remote` (git answered,
  no usable remote) / `unavailable` (never reached git).
- Enrichment persists `gitRemoteIdentity: null` only on `no-remote`,
  mirroring the existing `upstream: null` "not a fork" marker. An
  unreachable host leaves the identity undefined.
- Persistence keeps the explicit `null` instead of dropping it.
- `getTaskEligibleRepos` keeps a repo whose identity is still pending;
  folders and settled remote-less repos stay filtered out.

* test(tasks): cover the SSH probe exec paths for remote-identity status

Addresses CodeRabbit review: the unavailable-on-error case only exercised
the local git runner. Adds a connected-provider whose exec rejects, and an
SSH repo git answered for with no remotes.

* test(tasks): pin that a settled no-remote repo still resolves once it gains a remote

Three independent reviewers flagged that the candidate filter's `!repo.gitRemoteIdentity`
looks like an oversight next to the new null marker. Tightening it to `=== undefined`
would silently stop detecting a remote added after the marker landed. Document that the
re-probe is deliberate and pin the behavior with a test.
2026-07-25 04:04:19 -07:00
Brennan Benson 713fa40505 fix(runtime): reserve long-poll headroom so orchestration.ask can't starve terminal.wait (#10529)
* fix(runtime): reserve long-poll headroom so orchestration.ask can't starve waits

orchestration.ask joined the long-poll set, which also opted it into the
single server-wide activeLongPolls counter. Because ask blocks on a reply
for its full timeout (600 s default, previously unbounded via a caller
timeoutMs), 16 asking workers could hold every slot and shed terminal.wait
and check --wait with runtime_busy for every other client — mobile, web,
CLI, SSH and relay all share this runtime.

Meter ask as its own long-poll class with a sub-cap of half the budget, and
clamp the caller-supplied timeoutMs at 30 min. The keepalive and abort-signal
wiring that motivated the original change is unchanged.

* test(runtime): cover the ask sub-cap and counter release on the WebSocket path

The admission fence is shared by both transports but only the Unix-socket
path was exercised, so a WS-only regression in admitLongPoll/releaseLongPoll
would have shipped silently. Drives handleWebSocketMessage with a 'runtime'
scoped device (orchestration.ask is absent from the mobile allowlist) and
asserts the overflow ask is shed without burning a reserved slot, that
check --wait still gets the other half, and that both counters return to
zero when the socket closes.
2026-07-25 04:04:12 -07:00
Brennan Benson 9d02782969 fix(git): read core.sparseCheckout the way git does (#10537)
* fix(git): read core.sparseCheckout the way git does

Sparse-checkout detection parsed git config line-by-line and only accepted a
section header alone on its line, so git's legal same-line form
`[core] sparseCheckout = true` matched neither branch and was silently skipped:
a genuinely sparse worktree lost its badge and partial-checkout warning. It also
read `config.worktree` unconditionally, although git honors that file only while
extensions.worktreeConfig is on, so a stale worktree config could override the
repo's real setting.

Headers are now consumed left-to-right off each line (further headers and one
assignment may follow), and config.worktree is read only behind the extension
gate. Every new expectation was confirmed against real `git config --get`.

* test(git): correct what git actually does with a trailing-junk config value

Git does not reject `[core] sparseCheckout = true bogus = false` outright: it
parses the line and takes the whole tail as one value (`git config --list`
reports `core.sparsecheckout=true bogus = false`), then fails only the boolean
coercion. The expectation is unchanged; the comment now matches the binary.
2026-07-25 03:51:26 -07:00
NeilandOrca 7d47e9e1d8 fix(window): stop viewport reflow from moving screen geometry (#10543)
* feat(diagnostics): record OS suspend/resume so sleep gaps aren't read as freezes

Renderer timers stop across OS sleep, so a multi-hour `renderer_memory`
heartbeat gap looks identical to a wedged renderer. That ambiguity sent the
uber-crash investigation down a deadlock path that the telemetry later
disproved -- and a healthy machine's own trace shows a 427-minute mid-session
gap with reason=interval, so the gap alone proves nothing either way.

powerMonitor 'resume' was already wired for renderer wake recovery but left no
breadcrumb. Stamp suspend and report the measured span on resume so the next
freeze report can be told apart from a laptop lid.

* fix(diagnostics): only record sleeps long enough to hide a heartbeat

Adversarial review flagged that the first cut would flood the 30-entry
breadcrumb ring and evict the crash evidence it exists to explain. Measured on
7 days of pmset history: 70 user-visible sleep cycles, worst 60-min burst of 7.
Median span is 2 SECONDS -- only ~24% run past 60s, so most of that traffic
could never explain a gap anyway.

Cycles are counted Sleep -> next FULL Wake, since powerMonitor's resume maps to
NSWorkspaceDidWake, which does not fire for dark wake.

Drop the suspend breadcrumb (suspend now only stamps a timestamp) and emit a
single `system_slept` on resume, gated at 60s. That cuts 70 cycles to 17 over
the same week (worst burst 3) while still catching every sleep long enough to
swallow a 60s renderer heartbeat.

* test(diagnostics): assert resume listeners detach by identity

The off mock deleted by event name alone, so teardown detaching a
different closure than the one registered still passed -- a leak of the
real powerMonitor listener would have gone unnoticed. For 'suspend' that
leak has no other observable effect through the public API.

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

* fix(diagnostics): span from the first suspend across dark wake

powerMonitor 'resume' maps to NSWorkspaceDidWake, which does not fire for
dark wake, so macOS can deliver suspend -> suspend -> resume. Overwriting
the stamp reported only the trailing segment, and when that segment fell
under the 60s gate a 90-minute sleep recorded nothing at all -- leaving
the gap looking like the unexplained freeze this is meant to rule out.

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

* docs(diagnostics): correct the threshold rationale to match measurement

The comment claimed maintenance sleeps would flood the ring. Re-measuring
pmset over 6 days (78 sleeps, 29 full wakes, 51 dark wakes) shows they
resolve as DarkWake, which never fires 'resume', so they never recorded a
breadcrumb at all. Real rate is 29 breadcrumbs / 6 days, worst 60-minute
burst 4 against a 30-entry ring. The gate's actual job is narrower: skip
sleeps shorter than the 60s heartbeat, which cannot open a gap to explain.

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

* fix(window): stop viewport reflow from moving screen geometry

Blink's ScreenMetricsEmulator::Apply checks screen_size and view_position
before the desktop/mobile branch, so screenPosition:'desktop' does not make
them inert -- despite Electron documenting both as mobile-only. Passing the
content size and 0,0 overrode screen.width/availWidth and the window origin
for the whole 32ms hold, so a browser context menu opened mid-reflow would
translate against 0,0 and land in the wrong place (BrowserPane.tsx:3167).

Empty screenSize means 'no override', and an omitted viewPosition stays
nullopt in Electron's converter, so the real position survives. Only the
scale factor moves now, which is what the reflow actually needs.

Also record a breadcrumb when the restore exhausts its attempt budget: the
renderer is left at the wrong scale factor until some later reveal fixes it,
and that was previously silent.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-25 03:35:54 -07:00
Brennan Benson 5faf3b2ea0 fix(i18n): keep the macOS "Local Network" wording searchable in every locale (#10536)
Settings search indexed the macOS privacy-toggle name as English-only
aliases on the LAN keyword, so a Chinese user typing the term macOS
System Settings actually shows them (本地网络) got no hit — zh's catalog
value had been changed to 局域网 (LAN).

Split the two wordings onto their own catalog keys so each locale carries
both: 87620e6416 = LAN, fa3239cd42 = Local Network (its true content
hash). Localized values come from the repo's own LAN title translations
and from macOS 26's SecurityPrivacyExtension Localizable.loctable
(LOCAL_NETWORK), so nothing is invented. ja/ko/es already matched macOS
and are unchanged apart from gaining the LAN key.
2026-07-25 03:35:13 -07:00
Brennan Benson 9bc640addb fix(terminal): make primary-selection paste suppression single-shot (#10526)
* fix(terminal): make primary-selection paste suppression single-shot

Middle-clicking in the terminal armed a 750ms window that swallowed every
native paste event, not just Chromium's one follow-up — so a real Ctrl+V
inside that window was silently dropped on Linux.

Consume the deadline on first use (`shouldSuppress*` -> `consume*`) so the
arm owes exactly one event; 750ms stays as that event's expiry bound.

* test(terminal): cover the paste event xterm actually forwards to the PTY

Review follow-ups on the single-shot suppression change; no source change.

The new real-module file only synthesized `beforeinput`. An Electron
probe (Chromium 150) showed `paste` fires first, is cancelable, and
cancelling it at document capture suppresses `beforeinput` entirely — and
xterm registers `handlePasteEvent` for `paste` only, never `beforeinput`.
So `paste` is the sole event that can double-write the PTY, and it was
the one event the end-to-end file did not exercise. Add it; it fails with
the fix reverted.

Consuming mutates, so `isTerminalNativePasteTarget(...) && consume()` is
now load-bearing: swapped operands would burn the arm on an unrelated
paste and let the real follow-up double-paste. The guarding test asserted
only `defaultPrevented`, which survives the swap — assert `consume` is
never reached instead.

Rename the mocked-file case that claimed to prove single-shot. With the
module mocked its sequence is dictated by the mock; it pins that the hook
re-asks per event rather than caching, so name it that.

* test(terminal): name the beforeinput dispatcher for the event it dispatches

Round-2 review nit on my own round-1 change: once `dispatchClipboardPaste`
existed alongside it, a helper named `dispatchPaste` that dispatches
`beforeinput` inverted the reader's expectation. Match the sibling file's
`dispatchNativePasteBeforeInput` convention.
2026-07-25 03:33:53 -07:00
Brennan Benson 70b4d12067 perf(worktree): park the git-common existence poll while the window is hidden (#10528)
startGitCommonNarrowWatch was the only watch entry point that never received
WorktreePollerWindowVisibility, so its `worktrees/` existence poll kept
stat'ing every repo without a linked worktree forever in the background
(0.5 stat/sec/repo at the 2s default). Its siblings — the primary-metadata
snapshot poller and the non-darwin git-common polling — already park.

Threads visibility through and matches the snapshot poller's park/re-arm
pattern: the poll stops on the first hidden tick, and re-checks immediately on
onWindowBecameVisible so a worktrees dir created while hidden still upgrades to
the native stream and emits its create event. The visibility listener is
dropped in the dispose path.

darwin-only: the narrow watch is the `platform === 'darwin'` branch.
2026-07-25 03:33:10 -07:00
NeilandOrca f8b9b5c508 feat(diagnostics): record OS suspend/resume so sleep gaps aren't read as freezes (#10530)
* feat(diagnostics): record OS suspend/resume so sleep gaps aren't read as freezes

Renderer timers stop across OS sleep, so a multi-hour `renderer_memory`
heartbeat gap looks identical to a wedged renderer. That ambiguity sent the
uber-crash investigation down a deadlock path that the telemetry later
disproved -- and a healthy machine's own trace shows a 427-minute mid-session
gap with reason=interval, so the gap alone proves nothing either way.

powerMonitor 'resume' was already wired for renderer wake recovery but left no
breadcrumb. Stamp suspend and report the measured span on resume so the next
freeze report can be told apart from a laptop lid.

* fix(diagnostics): only record sleeps long enough to hide a heartbeat

Adversarial review flagged that the first cut would flood the 30-entry
breadcrumb ring and evict the crash evidence it exists to explain. Measured on
7 days of pmset history: 70 user-visible sleep cycles, worst 60-min burst of 7.
Median span is 2 SECONDS -- only ~24% run past 60s, so most of that traffic
could never explain a gap anyway.

Cycles are counted Sleep -> next FULL Wake, since powerMonitor's resume maps to
NSWorkspaceDidWake, which does not fire for dark wake.

Drop the suspend breadcrumb (suspend now only stamps a timestamp) and emit a
single `system_slept` on resume, gated at 60s. That cuts 70 cycles to 17 over
the same week (worst burst 3) while still catching every sleep long enough to
swallow a 60s renderer heartbeat.

* test(diagnostics): assert resume listeners detach by identity

The off mock deleted by event name alone, so teardown detaching a
different closure than the one registered still passed -- a leak of the
real powerMonitor listener would have gone unnoticed. For 'suspend' that
leak has no other observable effect through the public API.

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

* fix(diagnostics): span from the first suspend across dark wake

powerMonitor 'resume' maps to NSWorkspaceDidWake, which does not fire for
dark wake, so macOS can deliver suspend -> suspend -> resume. Overwriting
the stamp reported only the trailing segment, and when that segment fell
under the 60s gate a 90-minute sleep recorded nothing at all -- leaving
the gap looking like the unexplained freeze this is meant to rule out.

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

* docs(diagnostics): correct the threshold rationale to match measurement

The comment claimed maintenance sleeps would flood the ring. Re-measuring
pmset over 6 days (78 sleeps, 29 full wakes, 51 dark wakes) shows they
resolve as DarkWake, which never fires 'resume', so they never recorded a
breadcrumb at all. Real rate is 29 breadcrumbs / 6 days, worst 60-minute
burst 4 against a 30-entry ring. The gate's actual job is narrower: skip
sleeps shorter than the 60s heartbeat, which cannot open a gap to explain.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-25 03:11:59 -07:00
Brennan Benson ba434a4a93 fix(sidebar): stop a worktree drag preview from closing the Agent Dashboard (#10531)
The companion-board mutual-exclusion Effect keyed on
`workspaceBoardRenderedOpen`, which is `workspaceBoardOpen ||
workspaceBoardDragPreviewOpen`. WorktreeList sets the drag preview at the
start of every card drag — including a pure reorder within a group that
never opens the board — so dragging any card closed the Agent Dashboard
drawer, and cancelling the drag never restored it.

Key the Effect on `workspaceBoardOpen` so only the user actually opening
the board evicts the dashboard. The reciprocal Effect is unchanged.
2026-07-25 03:05:33 -07:00
Neil d8e0f112c6 perf(terminal): avoid repeated pending drain snapshots (#10163) 2026-07-25 02:38:20 -07:00
OrcaWin e1eca7f311 fix(agent-hooks): install OpenCode status plugin into the WSL guest so status works over WSL (#10328)
* fix(agent-hooks): install OpenCode status plugin into the WSL guest so status works over WSL

OpenCode reports agent status via a JS plugin dropped into OPENCODE_CONFIG_DIR
(unlike Claude/Codex, which use managed hooks.json scripts). Over the WSL
runtime that plugin was never materialized inside the guest and
OPENCODE_CONFIG_DIR never crossed into the guest, so OpenCode status never
reached Orca's sidebar (the workspace stayed green). Codex already worked; this
was OpenCode-specific.

Mirror the SSH plugin-overlay path for WSL:
- Guest relay registers AGENT_HOOK_INSTALL_PLUGINS_METHOD, byte-caps the source,
  and materializes an OpenCode config overlay via PluginOverlayManager (the same
  electron-free path the SSH relay uses), returning overlayDirs.opencode.
- Host manager ships the plugin source over the existing stdio channel after
  installers run (and on mid-session reinstall) and records the guest overlay
  dir; -32601 / CONNECTION_LOST / DISPOSED are swallowed like ssh-relay-session.
- PTY env points OPENCODE_CONFIG_DIR/ORCA_OPENCODE_CONFIG_DIR at the guest
  overlay; until the relay reports it (first spawn / older guest bundle) it drops
  those vars rather than crossing the Windows overlay path into WSL — so
  in-guest OpenCode falls back to its own config (pre-fix behavior, no
  regression).
- WSLENV passes OPENCODE_CONFIG_DIR/ORCA_OPENCODE_CONFIG_DIR through (/u for
  guest paths).

SSH is untouched: the same JSON-RPC constant is reused and the guest response
merely gains an optional overlayDirs field the SSH host ignores.

Runtime repro: native opencode launched in a WSL Orca terminal had
ORCA_AGENT_HOOK_PORT/ORCA_PANE_KEY but no OPENCODE_CONFIG_DIR and no Orca plugin
in ~/.config/opencode, so agentStatusByPaneKey stayed empty.

* fix(agent-hooks): stop the WSL OpenCode overlay leaking Windows paths and churning under running agents

Review fixes on top of the WSL OpenCode plugin install:

- Never cross a Windows OPENCODE_CONFIG_DIR into the guest. The /p flag was
  not a defensive default but WSLENV's translate-and-deliver flag, and
  buildWslRelaySpawnEnv spreads process.env while the daemon merge resurrects
  keys buildPtyHostEnv only deleted -- so a Windows value reached the guest as
  /mnt/c/... and was adopted as its OpenCode config root. Register the two
  vars only when the value is already a guest POSIX path.

- Make guest materialization idempotent. The overlay id is instance-scoped,
  and the host re-ships on every reinstall (60s after connect, and on later
  pane spawns), so materializeOpenCode's remove-and-rebuild wiped the config
  root under running agents and raced panes spawning against the path just
  handed to them. Rebuild only when the shipped source changed or the overlay
  went missing.

- Mirror the guest's default ~/.config/opencode (honouring XDG_CONFIG_HOME)
  when no explicit dir is discoverable, so pointing OPENCODE_CONFIG_DIR at the
  overlay no longer silently drops the user's models/agents/skills/mcp.

- Carry opencodeOverlayDir across relay relaunch; it is instance-keyed and on
  the distro's persistent filesystem, so dropping it only blanked status on
  panes spawned mid-relaunch.

* fix(agent-hooks): stop advertising a WSL OpenCode overlay the guest failed to rebuild

Round-2 review fixes:

- materializeOpenCode wipes before rebuilding, and every failure path after the
  wipe returns null leaving the dir present but plugin-less. The host treated
  that null the same as "no handler / teardown" and silently kept the previous
  value, so a pane could be pointed at an empty config root -- worse than the
  documented fallback of dropping the var. requestGuestOpenCodeOverlayDir now
  distinguishes 'none' (guest answered, no dir) from 'unavailable', and the
  manager clears the recorded dir on 'none'.

- The handler cache keyed only on plugin source, so a ~/.config/opencode created
  after the relay connected was never mirrored for the relay's lifetime. Key on
  the resolved source dir too, and validate the cache by the plugin file rather
  than the directory -- the directory is exactly what a failed rebuild leaves
  behind, so checking it alone made the bad state stick.

* fix(agent-hooks): don't mirror the XDG default OpenCode config into the WSL overlay

OPENCODE_CONFIG_DIR is APPENDED to OpenCode's config-dir list, not a
replacement for it. Verified against the shipped binary: the list is built as
[Path.config, ...project .opencode dirs, ...OPENCODE_CONFIG_DIR ? [it] : []],
and Path.config is derived independently from XDG_CONFIG_HOME/$HOME/.config.

So ~/.config/opencode is read whether or not Orca overrides the var, and the
earlier fallback that mirrored it into the overlay made OpenCode load the
user's config -- and their plugins -- twice. Resolve only an explicitly-set
dir, which is the one case that genuinely leaves the list when Orca overwrites
the variable. This also restores parity with the SSH and local paths.

* docs(agent-hooks): correct the WSL install-plugins cache comment and test framing

The per-call source-dir re-resolution comment still described the XDG default
branch that 6745eba90 removed. The relay's env is fixed for its lifetime and
the rc scan behind it is memoized, so the sourceDir cache key is defensive
rather than live -- say so, and retitle the test that simulates it by mutating
the captured env, so neither reads as coverage of a production scenario.
2026-07-25 02:30:59 -07:00
Brennan Benson d9aa919e3c perf(terminal): drop the JSON structural pre-scan from the history read path (#10499)
* perf(terminal): drop the JSON structural pre-scan from the history read path

readTerminalHistoryJson/Async walked every character of checkpoint.json in
interpreted JS before handing the same string to native JSON.parse. On a
26.8MB checkpoint that scan measured 63-330ms — 2.7-12x the JSON.parse it
guards, and 57% of the whole read path — all of it synchronous main-thread
work. readTerminalHistoryJsonAsync exists so cold-restore reads do not block
the main thread; running the scan inline right after the async read defeated
its own stated purpose.

The scan also had a correctness cost: TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS
was 1_000_000, and oscLinks is unbounded at ~10 structural tokens per link, so
roughly 100k OSC-8 hyperlinks tripped the assert, history-reader swallowed the
throw to a null checkpoint, and the terminal restored blank — the same
user-visible loss #10479 just fixed for the byte cap.

checkpoint.json and meta.json are our own SerializeAddon output, not untrusted
input: the byte cap still bounds the read, and a corrupt file fails JSON.parse
into the same catch. The shared helper stays for the call sites that do handle
untrusted JSON.

* test(terminal): pin iterative JSON.parse for the dropped nesting-depth cap

The retired pre-scan enforced two limits; the new tests only covered the
structural-token half. Dropping the 128-level nesting cap is safe solely
because V8 parses JSON iteratively — depth costs heap, not stack — so a
deeply nested checkpoint parses instead of overflowing. Verified: 10M-deep
arrays and objects parse without throwing on V8 14.6.

That property is an engine guarantee this code now silently depends on, and
a recursive parser would abort the daemon outright rather than throw into
the callers' catch. The test fails on main with "JSON nesting exceeds 128
levels" and costs 4ms.

* docs(terminal): drop the rot-prone benchmark figure from the reader comment

Keeps the durable rationale for skipping the pre-scan (self-authored input,
byte cap still bounds the read, corrupt files land in the callers' existing
catch) and moves the "~57% of the read path" measurement to the PR body,
where it cannot go stale against a later change to this path.

Addresses the CodeRabbit comment-length nitpick against the repo's
one-line-if-possible comment guideline.
2026-07-25 00:41:18 -07:00
Brennan Benson a0971e0f9b test(terminal): pin checkpoint-only cold restore of a large checkpoint (#10500)
* test(terminal): pin checkpoint-only cold restore of a large checkpoint

Triaging a report that checkpoint-only cold restore renders blank at every
checkpoint size found no v1.4.156 regression: an A/B of v1.4.155 against
origin/main returned byte-identical ColdRestoreInfo at 1.01, 5.56, 11.30 and
20.61 MiB, all 300 marker lines intact on both refs. Blankness tracks the
meta.endedAt eligibility gate, not size — a cleanly ended session refuses to
cold-restore at any size, which is by design and unchanged between the refs.

What the triage did surface is a coverage gap. #10179's 16MiB checkpoint read
cap sat under what the unbounded writer emits, so a large checkpoint threw,
was swallowed to checkpoint=null, and the pane reopened empty; #10479 raised
the cap but nothing pinned the round trip it had broken. history-reader-memory
covers the bounded reader at its limit, not writer→reader recovery.

Adds that round trip through the real HistoryManager writer and HistoryReader
over a header-only log, and pins the endedAt gate that has now been mistaken
for a size regression twice. Fails at the pre-#10479 cap with the exact
production symptom (detectColdRestore returns null).

* test(terminal): fail loudly when writeSync is unavailable

writeLargeScrollback ignored writeSync's boolean, which is false when
xterm's private _core.writeSync goes away. The size assertions catch that
in the large-checkpoint test (416 bytes vs 16MiB), but the endedAt gate is
size-independent, so that test silently passed on an empty snapshot —
pinning the gate over no scrollback at all.

* test(terminal): cut large-checkpoint fixture peak RSS from 1.2GiB to 800MiB

The filler colored per line, not per cell as its comment claimed, so the
serialized seed only tracked plain-text size and needed 26k buffer rows to
clear 16MiB. The xterm buffer costs rows x cols, which made this single file
raise the whole src/main/daemon/ suite's peak RSS 5.2x (241MiB -> 1244MiB) —
a real OOM risk on CI right after #10299 bounded readers for that reason.

Carrying the bytes in SGR runs instead of rows reaches a larger seed from
5.5k rows: suite peak RSS 1244MiB -> 793MiB, and the margins improve too
(seed 25.16MiB = 1.57x the threshold vs 1.19x before, checkpoint.json
46.1MiB = 2.88x vs 1.20x). Also makes the fixture match its own comment and
be more representative of real colored agent output.

Re-verified all three mutations still fail: cap at 16MiB -> "expected null
not to be null"; endedAt gate deleted -> test 2 fails; writeSync
unavailable -> both fail.

* test(terminal): tighten timeout, reuse prod dir-name helper, dispose first

Review follow-ups, all test-only:
- Import getHistorySessionDirName instead of hand-rolling encodeURIComponent.
  Equivalent today, but that helper exists to absorb encoding changes, so the
  hand-rolled copy would silently diverge from the writer it is checking.
- Index FILLER_ROWS by its own length so growing the array cannot leave rows
  unused.
- 300_000ms -> 60_000ms. The file runs in ~2.8s and vitest's own default is
  30s; a 5-minute ceiling turns a hung regression into a stalled job rather
  than a failure. 60s keeps Windows headroom.
- Take the snapshot, dispose, then checkpoint, so a throwing checkpoint()
  cannot leak the buffer. Note this is memory-neutral, not a saving: peak RSS
  is set at getSnapshot(), where the buffer and the serialized strings
  coexist, and measured ~800MiB either way.

Mutations re-verified: cap at 16MiB -> "expected null not to be null";
endedAt gate deleted -> test 2 fails; writeSync unavailable -> both fail.
2026-07-25 00:04:34 -07:00
Brennan Benson 9ae8f340ae fix(cli): explain SIGABRT serve exits instead of naming the signal (#10464)
* fix(cli): explain SIGABRT serve exits instead of naming the signal (#10461)

`orca serve` reported only "Orca serve exited via SIGABRT", which sent a P0
investigation down a code-signature path while a diagnostic crash report sat
unread on disk. On darwin + SIGABRT the signal-exit path now names the macOS
application-startup abort, its usual sandbox/SSH/CI causes, and points at
~/Library/Logs/DiagnosticReports/Orca-*.ips via the existing nextSteps channel.
Other platforms and signals get a clear message with no invented cause.

* fix(cli): stop asserting the SIGABRT exit happened at startup

* fix(cli): stop steering macOS SIGABRT users away from SSH serve
2026-07-24 23:28:26 -07:00
Brennan Benson 6c03ecf8e1 fix(terminal): stop cold restore dropping all scrollback on large checkpoints (#10479)
* fix(terminal): stop cold restore dropping all scrollback on large checkpoints

The checkpoint read cap shipped without its write bound. history-reader.ts
reads checkpoint.json through a 16MiB cap that throws past the limit, and the
catch swallows it to checkpoint=null; history-manager.ts still writes the
checkpoint with an unbounded JSON.stringify. Every fallback then collapses
(stale-generation log, unlinked legacy scrollback.bin), so the terminal
reopens empty with nothing surfaced.

Raise the read cap to cover the largest checkpoint the writer can legitimately
emit, derived from the scrollback policy's 50k-row max preset so it cannot
drift back under the writer. A bound is kept so a corrupt file still cannot
OOM the main process.

Bounding the writer instead would not recover the scrollback: the stringify
throw lands in handleWriteError, which adds the session to disabledSessions
and permanently stops history recording for it.

* fix(terminal): anchor checkpoint read cap to its own reasoning

The cap was derived as 2 * LEGACY_TERMINAL_SCROLLBACK_BYTES_100_MB, but that
constant is a legacy byte-preset setting value with no other consumer, and the
50k-row bucket it was attributed to has no upper byte bound. Same value, stated
without the false policy linkage.

* fix(terminal): assert the checkpoint byte cap, correct its rationale

The retained oversized-checkpoint test passed with the byte guard removed
entirely — 200MB of NUL fails JSON.parse, so detectColdRestore returned null
either way. Assert the bounded reader directly, as the amplification test does.

Also: a 50k-row max preset of ordinary text measures ~14MB serialized, not
'far below' by an unbounded margin — per-cell-colored output can still exceed
the cap, which is what a writer-side snapshot trim has to fix.
2026-07-24 22:55:16 -07:00
Brennan Benson 6592c01592 fix(daemon): keep agent-completion detection alive on pre-v27 daemons (#10478)
* fix(daemon): keep agent-completion detection alive on pre-v27 daemons

DaemonPtyAdapter.inspectProcess() threw terminal_liveness_unavailable when
the connected daemon predated protocol v27. The intended provider-level
fallback only fires when a provider lacks inspectProcess, so for the daemon
adapter the throw propagated: agent-completion-coordinator swallowed it into
consecutiveInspectionErrors and retried forever, killing process-exit
completions and pending-title validation.

Daemons intentionally survive app updates, so updating in place with agent
terminals open routes those PTYs to a legacy adapter and permanently
disables agent-finished notifications until the terminal is recreated.

Compose the inspection client-side from getForegroundProcess, which v26
fully supports. No new wire traffic and no new daemon capability.

* test(daemon): pin null-foreground semantics on the pre-v27 inspect fallback

The legacy composition had no coverage for a null foreground, which is the
one daemon response shape whose semantics diverge from v27: there
inspectProcess goes through getAliveSession() and throws for a vanished
session, while getForegroundProcess is deliberately null-not-throw. It is
also the only shape that reaches a user-visible completion, so reading it
as idle is a deliberate choice that should not change silently.
2026-07-24 21:48:36 -07:00
Brennan Benson 2653794c82 fix(terminal): verify Windows PTY root identity before taskkill /T /F (#10484)
* fix(terminal): verify Windows PTY root identity before taskkill /T /F

killWithDescendantSweep guarded its Windows tree kill with ownsRoot()
alone, which is JS state only. node-pty's ConPTY exit watcher closes the
last shell handle before it queues the JS exit callback, so Windows can
recycle the PID while the session map still looks live — force-killing an
unrelated process and its whole descendant tree.

Walk the recycled PID's ancestry back to this process before taskkill:
skip the sweep when the root is gone or resolves to a stranger, and keep
the sweep when identity is unknown so #10004 orphan cleanup still runs.
Also gate the local provider's ownsRoot on observed physical exit.

* fix(terminal): dedupe the Windows root-identity scan, drop dead exit gate

Review fixes on the PID-identity guard.

The probe read the process table through a new uncached export, bypassing
the reader that worktree teardown depends on: worktree-teardown.ts fans out
32-wide inside a 10s deadline, so a delete forked 32 powershell cold-starts
(the churn windows-foreground-process-rows.ts:25-32 warns about, #6288/#6667).
getFreshSnapshot() already guarantees a scan that starts after the request --
the exact property the bypass existed for -- and coalesces concurrent callers,
so use it. Measured on the new test: 32 scans -> 1.

The PhysicalExitTracker.hasExited gate could never fire. markExited() is only
reached at local-pty-provider.ts:985/:1431, and both are followed synchronously
by clearPtyState(), which deletes the ptyProcesses entry -- so ownsRoot's map
check is already false whenever hasExited is true. Reverting it broke no test.
Drop it and the shared getter it added; the identity probe already covers every
ownsRoot caller from inside killWithDescendantSweep.

Also point the Windows terminal-restart E2E job at the files that own this
behavior, so a change to the new Windows-only module runs the one job that
executes on a real Windows host.

* docs(terminal): state what the Windows root probe actually proves

The probe checks subtree membership, not root identity: a recycle that lands
on another Orca descendant (another pane's shell, an agent CLI, a git.exe we
spawned) still reads `own`, and that is not remote during teardown when Orca
is itself allocating pids. It bounds the blast radius rather than closing the
class. Say so at the type and at classifyWindowsTreeKillTarget, and name what
a real close would need (a CreationDate baseline -- the analogue of the POSIX
lstart check already used here -- or an inherited handle / Job Object).

Also note why our own pid must classify `foreign`.

* ci(windows): trigger the terminal-restart E2E on the shared snapshot reader

The Windows root-identity probe now reads through getFreshSnapshot, so an edit
to that module changes Windows teardown behavior without touching any path the
job already watches.

* test(terminal): guard the teardown probe against a reintroduced scan bypass

The existing volume guard covers queryWindowsProcessRowsFresh directly, but the
identity-probe cases all inject readRows, so nothing exercised the DEFAULT
reader wiring -- a bypass reintroduced inside windows-pty-root-identity would
have gone unnoticed. Drive verifyWindowsTreeKillTarget 32-wide through the real
reader and assert one scan. Verified it fails at 32 when the bypass is put back.
2026-07-24 21:39:58 -07:00
NeilandOrca 879aad7dd6 oom(foundation): bound shared readers/limits + add BoundedMap primitive (#10299)
* oom(01): A1-shared-readers — reintroduce #10179 subset

Files: 18 applied, 0 deleted (from 6eb70d8370)

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

* oom(02): A2-shared-image-media — reintroduce #10179 subset

Files: 7 applied, 0 deleted (from 6eb70d8370)

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

* oom(03): A3-shared-fs-listing — reintroduce #10179 subset

Files: 21 applied, 0 deleted (from 6eb70d8370)

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

* oom(04): A4-shared-remote-relay — reintroduce #10179 subset

Files: 8 applied, 0 deleted (from 6eb70d8370)

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

* oom(05): A5-shared-misc — reintroduce #10179 subset

Files: 28 applied, 0 deleted (from 6eb70d8370)

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

* oom(06): B-shared-wiring — reintroduce #10179 subset

Files: 81 applied, 0 deleted (from 6eb70d8370)

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 21:36:57 -07:00
NeilandOrca cafa958e70 fix(ui): stop the reveal reflow from dismissing popovers (#10487)
* fix(ui): stop the reveal reflow from dismissing popovers

The main process reflows the renderer on every reveal, resume, and restore.
On macOS 26 that nudges the emulated device scale factor, and Chromium fires
a real window resize for it even though innerWidth/innerHeight are identical.
Anything bound directly to resize treated that as a user resize.

The selection copy menu and the markdown link bubble both dismiss on resize
unconditionally, so they closed on their own whenever the window was revealed
or restored. Gate both on an actual dimension change.

Only fixes the macOS 26 path: the pre-Tahoe repaint jiggles the native frame
by a real pixel, so the renderer sees a genuine dimension change and no
change-detection guard can — or should — filter it.

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

* test(ui): cover the reveal-reflow guard at the component level

Pins both halves of the wiring: a same-size resize must not dismiss, a real
one still must. Mutation-checked — reverting to a bare resize listener, or
dropping the listener entirely, each fails the suite.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 21:11:48 -07:00
NeilandOrca 46942783c4 fix(window): reflow via scale factor so terminals stop re-gridding (#10485)
The +1px emulated viewport changed the CSS box, so a terminal sitting one
pixel under an xterm row boundary gained a row. The pane fit observer's
two-frame stability check reads that transient grid as stable well inside
the 32ms hold, forwards a real PTY resize, then reverses it on restore —
two SIGWINCHes per reveal, measured on 3/54 window heights (~1/cellHeight).

Nudging the device scale factor instead re-runs layout with byte-identical
CSS geometry. Same reflow, 0/54 SIGWINCH, no WebGL atlas rebuild or context
loss, and webview guests stop seeing spurious native resizes too.

Co-authored-by: Orca <help@stably.ai>
2026-07-24 20:53:04 -07:00
NeilandOrca 81bfb3c396 oom(02/29): bound shared image/media/PDF memory limits (#10295)
* oom(01): A1-shared-readers — reintroduce #10179 subset

Files: 18 applied, 0 deleted (from 6eb70d8370)

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

* oom(02): A2-shared-image-media — reintroduce #10179 subset

Files: 7 applied, 0 deleted (from 6eb70d8370)

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 20:31:06 -07:00
NeilandOrca c419aadb19 oom(01): A1-shared-readers — reintroduce #10179 subset (#10294)
Files: 18 applied, 0 deleted (from 6eb70d8370)

Co-authored-by: Orca <help@stably.ai>
2026-07-24 20:30:31 -07:00
Neil c468e3f8b8 fix(macos): close both macOS 26 main-thread deadlock doors and restore the reveal reflow (#10473)
* fix(window): restore the macOS 26 reflow without touching the native frame

#10253 stopped the main-thread deadlock by skipping the repaint size nudge on
macOS 26, but invalidate() repaints without reflowing, so the h-dvh root kept a
stale viewport height and the status bar stayed clipped off-screen (STA-2383) on
every Tahoe reveal, restore and wake.

Drive the reflow through device emulation instead: a +1px emulated viewport,
reverted a frame later, makes the renderer recompute layout without any NSWindow
mutation, so the FrontBoardServices re-entrancy that wedged the main thread for
109 minutes is still never triggered.

Verified against real Electron 43.1.0 on macOS 26.3.1 (Darwin 25.3.0): the
renderer sees the resize and relayouts, the native frame is untouched, the
viewport and devicePixelRatio restore exactly across zoom levels, overlapping
calls collapse to one cycle, and a 1px delta never crosses a terminal cell
boundary so no pane reports new geometry (no SIGWINCH to running shells).

Also close two gaps in the surrounding code:
- the pre-Tahoe size jiggle now clears its WeakSet latch in a finally block, so a
  throwing setSize can no longer suppress every later repaint for that window
- cover powerMonitor 'resume' under the Tahoe guard, the other AppKit dispatch
  context implicated in the freeze

* fix(tray): keep NSStatusItem scene updates off the AppKit callout stack

The main-window repaint was only one of the two doors into the macOS 26
FrontBoardServices deadlock. Showing or restoring the window calls
setTrayAttention(false) straight from the window event handler, and
tray.setImage/setToolTip drive an NSStatusItem scene update — the same
re-entrant scene mutation from inside AppKit's own dispatch, matching the
stackshot in openai/codex#23695.

Defer the native mutation to a fresh event-loop turn so the callout frame is
vacated first. The attention flag itself still flips synchronously: rapid
show/hide would otherwise mis-dedupe against a value that had not landed yet.

Bursts collapse to a single repaint, and because the deferred pass reads current
module state rather than a captured value, a coalesced schedule can never apply
a stale icon. applyTrayImage already no-ops on a destroyed tray, so a repaint
still queued when the tray goes away is harmless.

* fix(window): reflow maximized and fullscreen windows on macOS 26 too

The maximized/fullscreen bail-out predates the Tahoe path and exists only to
keep the size nudge from resizing a window out of those states. Emulation never
touches the frame, so that guard was suppressing the reflow for no reason — and
a maximized window strands its dvh layout exactly like a normal one.

Run the Tahoe branch before the guard. Verified on macOS 26.3.1 that the
emulated viewport reflows a maximized and a fullscreen window while leaving both
states intact.

* fix(window): retry the viewport restore instead of stranding the renderer

If disableDeviceEmulation threw while the webContents was still alive, the
previous code swallowed the error and cleared the latch anyway, leaving the
renderer pinned at the emulated 1px-taller viewport for the rest of the window's
life — and letting the next reveal stack a fresh cycle on top of it.

Retry the restore on a bounded schedule and hold the latch while a retry is
pending. A destroyed webContents still short-circuits, since the emulated
viewport dies with it, and the attempt budget keeps a permanently failing
restore from pinning the latch forever.
2026-07-24 19:54:25 -07:00
OrcaWin b4718dd05c fix(terminal): resume hibernated agents that reattach with no payload (#9648)
* fix(terminal): resume hibernated agents that reattach with no payload

When agent hibernation is on, a stopped (done) agent's PTY is killed and a
passive sleeping record is kept; returning to the worktree relies on the pane
reattaching on remount. On the daemon path (Windows/local worktrees) the daemon
can reattach the hibernation-killed session as already-live (isReattach,
isNew:false) and return no snapshot/replay/coldRestore — and, being a reattach
rather than a fresh spawn, it silently drops the --resume command passed on
connect. The renderer adopted that empty session, leaving a blank terminal with
nothing running and the sidebar history still pointing at the dead tab (the
sleeping record never cleared).

A reopened pane that owns a resumable slept session must always be re-driven
with its resume command, never left as a bare empty attach. handleReattachResult
now discards a contentless isReattach for a pane with a hibernation record and
re-drives the prepared resume. This excludes the healthy cases: a fresh session
the daemon created (isReattach falsy — it already ran the command) and a live
reattach (carries a snapshot/replay). Forward the isReattach signal the
transport was dropping so the two cases are distinguishable.

Adds a deterministic regression test that fails without the guard and passes
with it.

* fix(terminal): preserve provider ownership on resume

* chore(skills): refresh generated skill bundle manifests

Regenerate the skill bundle artifacts against the full release-tag set so
the freshness verify check passes. Append-only additions for the newer
release tags; no released snapshot history is rewritten.
2026-07-24 18:40:05 -07:00
Brennan Benson c3526cc19d feat(codex): surface a stalled config sync instead of failing silently (#10449)
* feat(codex): surface a stalled config sync instead of failing silently

Why: the mirror keeps serving the last synced settings when ~/.codex/config.toml
is missing, blank, or unreadable. That is the right call for data safety, but it
is invisible — a downed WSL distro or an unhydrated cloud-synced home leaves
"Orca ignores my config edits" with no log line and no UI to diagnose.

Status is derived on demand from the same predicates the mirror uses, so the two
cannot disagree. The stall is logged once per episode rather than on every launch
and quota poll, and the Codex account section names the file and what to do.

* fix(codex): latch an unreadable source and stop over-claiming recovery

An unreadable source throws out of the mirror, so reporting only on the success
path left that stall latch-less: it logged the raw failure on every launch and
quota poll while its reason never reached the surfaced status. Report from the
catch path too.

The clear message also claimed the source was "readable again", which is false
when the stall ended because the runtime config was removed rather than because
the source came back.

Restoring console.warn now happens in afterEach — an inline mockRestore is
skipped by a failing assertion, and the leaked spy made every later case in the
block fail spuriously.

* fix(codex): latch the stall promotion hits first, and scope it to the host

Review round 1 findings:

- The unreadable-source latch still never fired in the steady state. Once a
  baseline exists, promotion reads the source before the mirror does, so it
  throws first and `!promotionPlan` returned before any reporting — logging a
  reasonless failure every launch and quota poll, which is exactly what the
  previous commit claimed to fix. Report from that branch too. The test only
  passed because its fixture had no baseline; it now seeds one first and fails
  without the fix.
- The banner named the host's ~/.codex while a WSL or per-account runtime was
  selected, whose real source is a different file entirely. Gate it to the host
  scope, matching how the sign-in warning is already gated.
- Three new translate keys were missing from the locale catalogs, failing the
  localization gate in `pnpm lint`.
- The registrar mock was never asserted, so deleting the registration left the
  suite green.
- `codexConfigSyncStatus` hung off the `agentHooks` namespace despite having
  nothing to do with agent hooks; moved to its own `codexConfigSync.status`
  while it is still a four-file change.

* fix(codex): report sync health for the home the selection actually mirrors

Review round 2:

- The status resolved the shared runtime home, but the system default now runs
  Codex directly against ~/.codex and managed accounts get their own home. So a
  stalled per-account mirror showed no banner at all, while a stale shared home
  could warn about a config the active lane never reads. Resolve the mirrored
  home from the current selection, and report synced when the lane has no mirror
  to fall behind.
- The round-1 report on the promotion failure path could clear the latch on a
  pass where no mirror ran, claiming a recovery that never happened and
  silencing every later pass. Only ever latch a stall there; leave clearing to
  the path that actually mirrored.

* fix(codex): refetch sync status when the active Codex account changes

Review round 3:

- Resolving the status per selection made the fetch account-dependent, but the
  effect was not keyed on the active account. Switching accounts left the banner
  describing the previous one — and switching INTO a stalled account showed
  nothing at all, which is the silence this change exists to remove.
- Pin the home resolution itself: it had no direct test, and its shared-home
  path was a hand-copied literal that could drift from the real helper and
  silence the banner with every other test still green.
- Narrow the handler's dependency to the one method it calls, which also drops
  an `as unknown as` cast from its test.
- Skip the chmod-based test on Windows, where a read-only directory does not
  block writes so the scenario cannot be constructed; matches the convention
  already used in config-settings-promotion.test.ts.

* chore(codex): restore the handler docstring and isolate the resolver suite

Round 4 returned clean; these are its two non-blocking nits.

Narrowing the handler param left its JSDoc stranded above the new type, so the
function had no hover doc. The resolver suite also read the developer's real
CODEX_HOME and shell rc, so anyone exporting one would see it fail locally.
2026-07-24 18:30:54 -07:00
Brennan Benson 9acb685816 fix(ssh): re-arm remote watches that die without a reconnect (#10470)
* fix(ssh): re-arm remote file watches when the provider reconnects

Remote file changes stopped being detected over SSH until the file was
reopened. The watch pipeline itself was fine — nothing ever re-established
the subscription after the transport it was made on went away.

Two paths left an editor tab permanently stale:

- A reconnect kills the relay's watch registrations, and the previous
  provider's unwatch handle belongs to the dead transport.
- A connect slower than the 60s retry window made installRemoteWatcher
  give up for good; first deploy to a new host far exceeds that.

Neither recovered, because installRemoteWatcher is only reachable from the
fs:watchWorktree handler, the retry timer, and the removal-restore path,
and the renderer only issues a watch for newly added targets. Reopening
the file just re-read it — the watcher stayed dead.

Give the layer that owns the transport the job of re-arming:
registerSshFilesystemProvider now notifies subscribers, which covers both
establish and reconnect since registerProviders runs on both. The watcher
keeps the intent to watch in a registry that outlives any single
connection, and on registration drops the stale entry (installRemoteWatcher
treats an existing entry as installed and would otherwise hand back a
watcher that can never fire), reinstalls, and emits overflow so consumers
resync the gap. Intent is dropped on unwatch and sender destroy so a
closed tab is never resurrected.

Verified over SSH to a Rocky Linux 10 host: after the reconnect that
previously killed it, a remote append lands in the editor in ~2s with a
live remote watcher process, and a second edit in ~1.5s.

* fix(ssh): drop watch intent when a remote worktree is removed

A removed worktree kept its entry in the intent registry, so a reconnect
landing before the renderer's unwatch would re-watch a deleted path —
60s of retries against the host and then a bogus overflow.

Also covers two reinstall cases: several senders on one connection must
collapse onto a single relay watch (and all of them resync), and a
destroyed renderer must not be reinstalled.

* fix(ssh): resync when a reconnect's watch only lands on a retry

The reinstall emitted the overflow only for listeners whose first install
returned 'installed'. If that attempt failed — relay-watcher.js still
spawning on the fresh transport, a transient fs.watch rejection — the 1s
retry restored the watch but never signalled the gap, so everything that
changed while the transport was down stayed invisible: the STA-2525
symptom reappearing inside the fix for it.

Thread the resync intent through the retry record so the overflow lands
when the retry does. All pre-existing callers default to false, so the
watch/terminal-error/restore paths are unchanged.

* test(ssh): cover the resync merge when a fresh watch claims the retry slot

The `resyncOnInstall ||=` merge was uncovered: removing it left every
existing test green. It is load-bearing — if a second renderer joins the
reinstall's failing install and reaches the retry slot first with
resync=false, the whole chain stays false and the retry restores the
watch without ever signalling the gap.

New sibling file rather than an append: filesystem-watcher.test.ts is
~10 counted lines from the 800-line lint cap, and AGENTS.md forbids a
max-lines disable.

* fix(ssh): re-arm a remote watch that died without a reconnect

The 60s fast-retry window gave up with a single overflow and no further
trigger — provider registration is the only re-arm, and a watch killed by
remote OOM/inotify exhaustion leaves the SSH link perfectly healthy. Back
off from 1min to a 30min ceiling instead, and stand down entirely when the
provider is gone so registration owns that case.

* fix(ssh): re-arm the remote file-explorer watch on reconnect

SshFilesystemProvider.dispose() stops each watch registration without
invoking its terminal callbacks, so a dropped transport left the runtime
file-explorer watch silently dead: the lease's restart only runs from the
failed-removal path, and the renderer's runtime subscription stays open
because it rides a different link. Reinstall on the connection's next
provider registration and emit overflow so clients resync.
2026-07-24 18:18:40 -07:00
Brennan Benson b9a7c00df5 fix(editor): read out-of-worktree SSH paths without a stamped target id (#9743) (#10455)
* fix(editor): read out-of-worktree SSH paths without a stamped target id (#9743)

Route external absolute-path tabs by their resolved SSH connection instead of
the externalSshTargetId stamp, which only the terminal-link open path sets. The
stamp keeps its fail-closed role when present.

* fix(editor): keep client-local live-tail log tabs off the worktree SSH host

AI Vault "View Log" tabs are opened client-local by construction (readOnly +
liveTail, no runtime env), so inferring the external SSH owner from the
worktree connection made them read the remote host instead of the granted
client path. Restrict that inference to non-live-tail tabs; an explicit
externalSshTargetId stamp still routes remotely.
2026-07-24 17:38:03 -07:00
Brennan Benson fb5ced64a7 fix(ssh): re-arm remote file watches when the provider reconnects (#10445)
* fix(ssh): re-arm remote file watches when the provider reconnects

Remote file changes stopped being detected over SSH until the file was
reopened. The watch pipeline itself was fine — nothing ever re-established
the subscription after the transport it was made on went away.

Two paths left an editor tab permanently stale:

- A reconnect kills the relay's watch registrations, and the previous
  provider's unwatch handle belongs to the dead transport.
- A connect slower than the 60s retry window made installRemoteWatcher
  give up for good; first deploy to a new host far exceeds that.

Neither recovered, because installRemoteWatcher is only reachable from the
fs:watchWorktree handler, the retry timer, and the removal-restore path,
and the renderer only issues a watch for newly added targets. Reopening
the file just re-read it — the watcher stayed dead.

Give the layer that owns the transport the job of re-arming:
registerSshFilesystemProvider now notifies subscribers, which covers both
establish and reconnect since registerProviders runs on both. The watcher
keeps the intent to watch in a registry that outlives any single
connection, and on registration drops the stale entry (installRemoteWatcher
treats an existing entry as installed and would otherwise hand back a
watcher that can never fire), reinstalls, and emits overflow so consumers
resync the gap. Intent is dropped on unwatch and sender destroy so a
closed tab is never resurrected.

Verified over SSH to a Rocky Linux 10 host: after the reconnect that
previously killed it, a remote append lands in the editor in ~2s with a
live remote watcher process, and a second edit in ~1.5s.

* fix(ssh): drop watch intent when a remote worktree is removed

A removed worktree kept its entry in the intent registry, so a reconnect
landing before the renderer's unwatch would re-watch a deleted path —
60s of retries against the host and then a bogus overflow.

Also covers two reinstall cases: several senders on one connection must
collapse onto a single relay watch (and all of them resync), and a
destroyed renderer must not be reinstalled.

* fix(ssh): resync when a reconnect's watch only lands on a retry

The reinstall emitted the overflow only for listeners whose first install
returned 'installed'. If that attempt failed — relay-watcher.js still
spawning on the fresh transport, a transient fs.watch rejection — the 1s
retry restored the watch but never signalled the gap, so everything that
changed while the transport was down stayed invisible: the STA-2525
symptom reappearing inside the fix for it.

Thread the resync intent through the retry record so the overflow lands
when the retry does. All pre-existing callers default to false, so the
watch/terminal-error/restore paths are unchanged.

* test(ssh): cover the resync merge when a fresh watch claims the retry slot

The `resyncOnInstall ||=` merge was uncovered: removing it left every
existing test green. It is load-bearing — if a second renderer joins the
reinstall's failing install and reaches the retry slot first with
resync=false, the whole chain stays false and the retry restores the
watch without ever signalling the gap.

New sibling file rather than an append: filesystem-watcher.test.ts is
~10 counted lines from the 800-line lint cap, and AGENTS.md forbids a
max-lines disable.
2026-07-24 17:37:01 -07:00
moseohandBrennan Benson 1aaf049a4d fix(rate-limits): keep Codex PTY reset text for weekly-only plans (#8643)
* fix(rate-limits): keep Codex PTY reset text for weekly-only plans

The PTY /status fallback parses '5h limit' and 'Weekly limit' lines by
label, but the extracted reset text was only ever attached to the
session window. Codex plans without a 5h session bucket (e.g. current
Pro) produce a weekly-only parse, so the reset time the CLI printed was
silently dropped. Fall back to the weekly window when no session window
exists.

* review: parse Codex PTY reset text per window into resetsAt

* review: make Codex PTY status fallback work on codex >=0.145

* review: harden PTY status parse against model-scoped rows and styled output

* fix(rate-limits): strip private PTY control sequences

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-24 16:26:55 -07:00
Brennan Benson e133d93b7b fix(skills): stop promising a skill update the command cannot deliver (#10436)
* fix(skills): stop promising a skill update the command cannot deliver

An "Update available" badge could never clear: pressing Update ran
`npx skills update <name> --global`, which reported "All global skills are
up to date" and wrote nothing, while the badge stayed on.

Freshness marked a name updatable whenever ANY placement was outdated,
including a standalone duplicate in an agent home. The global command only
converges the canonical copy and its symlink aliases, so a stale duplicate
kept the badge lit with no command that could clear it.

- Count only reliably-convergent placements toward the update promise, so a
  stale duplicate no longer advertises an update that cannot land.
- Give the duplicate chip a real skipped-reason instead of falling through to
  the generic sentence, naming the copy and how to resolve it.
- Surface the existing freshness review dialog from the setup rails via a
  Details link, so a blocked or duplicate copy is explainable where the user
  actually sees the badge. Covers orchestration, Computer Use, Ephemeral VMs,
  Linear, the CLI section, the floating orchestration modal, the Browser Use
  card, and the Mobile Emulator row.

* fix(skills): say when a skill copy needs attention instead of reading as all-clear

A skill with an out-of-date copy the update command cannot reach rendered as a
green "Installed" pill. That is honest about the main copy but reads as
all-clear, so real drift in an agent home stayed invisible — the user had no
reason to suspect there was anything to click.

- Add a needs-attention display status (amber) for a placement that is not
  current but has no eligible update: stale duplicates, edited copies,
  read-only, inaccessible, and broken or external links. Presence-only stays
  green, and an unscanned inventory stays quiet so nothing flashes amber on
  launch.
- State the reason inline on the setup rails, so the cause is readable without
  opening the review dialog; Details still opens the full per-location list.
- Extract the skipped-reason sentence into its own module so the rails and the
  dialog share one source and can never drift apart.
- Carry the state through the settings sidebar badge so the nav and the card
  cannot disagree.

* fix(skills): give the inline skill warning something to point at

The shared reason sentences are deictic — "this copy", "the copy here" — because
they were written for the review dialog, where the location rows they describe sit
directly beneath them. On the setup rails there are no rows, so "this" referred to
nothing and the sentence read as if it were about the skill itself.

Name the offending paths above the sentence on the rails, so the referent is
present before the wording that depends on it. Every copy sharing the blocking
reason is listed, not just the first, so resolving one does not leave the badge
unexplained. The dialog keeps its existing wording and rows unchanged.

* fix(skills): stop withholding the update over copies it cannot reach

Eligibility is now decided purely over the placements the global command actually
converges — the canonical copy and its symlink aliases.

A project skill, plugin cache, standalone duplicate, or unreadable copy in another
agent's home used to withhold the update from the whole name. `skills update
--global` provably never writes any of them, so that refused work the command could
have done over a copy that was never at stake. A blocked *convergent* copy still
withholds it: that is the placement the command writes to, and overwriting it is the
real data-loss case.

Also drop the inline reason from the setup rails. The reasons are written to sit
beside the location rows they describe, so on a card they had nothing to point at and
could only ever name one cause. The rails now mark Details with a warning icon when a
copy needs the user's own hands, and the dialog does the explaining with every
location and cause it knows about.

* fix(skills): make the skill Details affordance read as a control

The review link rendered as bare text, so nothing but hover said it could be
clicked — the badge told the user something was wrong and then gave them no visible
way in.

Use the ghost variant so it carries a hover/focus background and a real hit target
instead of a zero-padding text run, and add a chevron so it reads as a control at
rest. The chevron points right, not down: this opens the review dialog, while a down
chevron already means the in-place expander inside that dialog.
2026-07-24 15:53:22 -07:00
Brennan Benson b600e25fa1 feat(worktrees): support project-level .worktreeinclude (literal paths) for copying gitignored files into worktrees (#9791)
* feat(worktrees): copy project-level .worktreeinclude paths into new worktrees

Read .worktreeinclude at the repo root (gitignore syntax) and copy matching
gitignored paths from the primary checkout into each newly created local
worktree, so .env and other local config carry over with zero per-user setup.

- Literal patterns resolve by direct stat; globs match against
  ls-files --others --ignored --exclude-standard --directory (collapsed
  dirs keep huge repos fast); every candidate is re-verified with
  check-ignore so tracked or unignored files are never copied.
- Copy semantics, never symlink: APFS clone-copy on macOS, real copy
  elsewhere, so each worktree owns its files (unlike repo.symlinkPaths,
  which it merges with rather than replaces).
- Failures never block worktree creation.
- Remote (SSH) creation skips it, same as symlinkPaths.
- Split APFS clone helpers into worktree-apfs-clone.ts (max-lines).

Closes #7549

* fix(worktrees): harden worktree include copying

* fix(worktrees): support nested includes on Git 2.25

* fix(worktrees): bound include copy costs

* fix(worktrees): close include correctness and perf gaps

* fix(worktrees): preserve included copy semantics

* fix(worktrees): harden include resolution

* fix(worktrees): preserve bounded include resolution

* fix(worktrees): bound include filesystem resolution

* fix(worktrees): harden include matching

* fix(worktrees): tighten include matching and scan bounds

* fix(types): use concrete filesystem stat types

* fix(worktrees): harden included path materialization

* perf(worktrees): stop include parsing at resolver budgets

* chore(skills): refresh bundled skill manifests

* refactor(worktrees): reduce .worktreeinclude to focused literal-only scope

The reviewed implementation grew well past the ticket (#7549), which asks for a
size-M feature that reuses existing worktree machinery. Trim back to the minimal
change that solves the reported problem safely:

- Resolver now supports literal files and directories only. Glob/negation lines
  are skipped with a warning (documented follow-up), which removes the entire
  user-controlled-regex ReDoS surface, the CPU/byte budgets, the git enumeration
  scan, and the case-sensitivity engine. The filesystem + git check-ignore
  handle existence and case for free.
- Copy layer folded back into worktree-symlinks.ts (link/copy modes share one
  loop); dropped worktree-path-copy.ts, worktree-target-safety.ts, the
  descendant-dedup/realpath/target-parent machinery, and the per-materialization
  APFS filesystem cache. Kept the df/diskutil probe timeout.
- Reverted unrelated changes: check-ignored-paths timeout param and the
  git-binary-compatibility enumeration tests.

Net: -1903/+172 across the include+copy code. Behavior for the ticket's cases
(.env, .env.local, .vscode/, node_modules, config/secrets.json) is unchanged;
gitignored-only + copy-not-symlink semantics preserved.

Closes #7549

* fix(worktrees): dereference symlinked .worktreeinclude entries + cache APFS volume probe

Two issues found by review + perf audit of the copy path:

- Correctness (HIGH): a listed entry that is itself a gitignored symlink was
  copied AS a symlink (fs.cp dereference:false), and the darwin APFS branch was
  skipped for all symlink sources. Editing the worktree's copy then wrote through
  to the shared/primary target — inverting copy-mode's 'each worktree owns its
  files' guarantee, and escaping the worktree entirely if the link pointed
  outside it. Now resolve realpath for a top-level symlink in copy mode so we
  copy content; nested symlinks inside a copied dir stay as-is (cp -R semantics).

- Perf: assertSameApfsVolume ran df+diskutil per copied path (4 subprocesses
  each), so an N-entry include spawned ~4N short-lived processes on the macOS
  create hot path, all re-probing one volume. Add a per-materialization
  device-keyed cache: one probe per distinct volume (4N -> ~4).

Tests: symlinked-file and symlinked-dir dereference regressions (no leak to
primary); APFS volume probed once regardless of copied-path count.
2026-07-24 15:31:23 -07:00
kazu-42andBrennan Benson 4a71a0ecb2 feat(mobile): add safe Codex rate-limit resets (#9394)
* feat(mobile): add safe Codex rate-limit resets

* fix(mobile): address reset credit review feedback

* review: purge removed-account reset attempts, shared capability constant, rebase test mocks

* review: preserve host compatibility and reset durability

* fix(mobile): recover reset capability after cutover

* fix(mobile): validate runtime capability payloads

* fix(mobile): enforce capability payload contract

* fix(mobile): route mock terminals to selected worktree

* test(mobile): pin malformed probe retry behavior

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-24 14:42:04 -07:00
OrcaWin 402b5a1cc8 fix(win): taskkill plain-shell PTY trees on immediate teardown (#10339)
* fix(win): taskkill plain-shell PTY trees on immediate teardown

Deleting a Windows worktree that still has a live process in a terminal
tab (a `pnpm i`, or any command that spawns a child tree) failed with
"Failed to physically stop every PTY for worktree" after the full 10s
teardown deadline.

Root cause: on Windows, closing a plain shell's ConPTY does not reap its
orphaned children — node-pty's `useConptyDll` skips the console-process
reap. A live `pnpm i`/`node` child survives the shell's exit, keeps the
ConPTY console non-empty (so the daemon still reports the session alive),
and holds the worktree cwd handle. The destructive-removal physical-stop
check then fails closed. #10100 fixed this for agent sessions
(killWithDescendantSweep -> taskkill /T /F) but plain shells were never
swept.

Fix: extend the Windows taskkill /T /F descendant tree-kill to non-agent
shells on the immediate/destructive teardown path, in both the daemon
(TerminalSessionTeardown) and local provider. Gated to win32 + immediate
with the same ownsRoot guard so a naturally-exited/recycled PID is never
signalled; POSIX shells keep reaching their child pgroup via forceKill
and are unchanged.

Reproduced and verified end to end via the Electron dev build: a worktree
with a live node child previously failed to delete after 10s; with the
fix the child tree is taskkilled, the directory is removed, and the
delete succeeds in ~1.8s.

* fix(win): claim plain-shell termination before the taskkill sweep

forceKillAndWaitForExit sets _isTerminating in its synchronous prologue.
Awaiting the Windows descendant sweep ahead of it left createOrAttach's
doomed-session guard open for the taskkill's duration, so a concurrent
attach could bind a pane to a session about to be tree-killed.
2026-07-24 14:12:51 -07:00
Brennan Benson e058370c78 test(runtime): guard both halves of the headless hydrate repo gate (#10443)
#9343 broke two contracts at once and #10437 fixed both, but neither is
asserted: #10429 repaired the failing tests by deleting the poll assertion and
by handing the retirement store a live repo, so a revert would land silently.

Restore `expect(getRepos).not.toHaveBeenCalled()` on the floating-tab poll, and
add a hydrate case for a store that cannot report repos. Verified both fail
against the pre-#10437 code: the poll assertion reports getRepos "called 2
times", and the hydrate case returns [] instead of the persisted tab.
2026-07-24 14:01:48 -07:00
kin001andBrennan Benson 6a72c8f120 fix(codex): preserve runtime config when system source is missing (#9127)
* fix(codex): preserve runtime config without system source

* fix(codex): retain baseline when mirror is skipped

* refactor(codex): extract deprecated hook-flag normalization

Why: codex-config-mirror.ts sat at the 300-line cap, so the missing-source
guard could not land without a max-lines disable.

* fix(codex): bootstrap a baseline when the mirror is skipped

Why: a runtime home seeded outside the mirror (WSL, per-account) never got a
baseline while the source was missing, so promotion stayed inert and silently
reverted the in-Codex change once the source returned.

* fix(codex): stop a synthesized source config from wiping runtime settings

Two routes still reached the #9073 data loss after the missing-source guard:

- Promotion runs before the guard and, with no ~/.codex/config.toml, created
  one holding only the promoted keys. The next mirror treated that skeleton as
  authoritative and deleted every other runtime setting. It needs no missing
  file: `codex mcp add` inside an Orca-launched Codex plus /model was enough to
  drop the MCP server for good. Promotion now seeds a brand-new system config
  from the runtime's ordinary settings, so the mirror round-trips them.
- A 0-byte source (half-written, or an unhydrated cloud-synced home) still read
  as an authoritative empty config and advanced the baseline, making the loss
  unrecoverable. A blank source is now treated like a missing one.

Moves the TOML section model out of codex-config-mirror.ts so promotion can
share it without a cycle.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-24 13:38:10 -07:00
Brennan Benson 20ceeeb0f4 fix(runtime): stop the headless hydration repo gate from dropping every tab (#10437)
* fix(runtime): stop the headless hydration repo gate from dropping every tab

#9343 gated headless mobile-session hydration on the live repo list, but read
it as `this.store?.getRepos?.() ?? []`. A store that cannot report repos then
yields an empty set, which reads as "every repo is gone" and skips every
parseable session key — so no tabs hydrate at all. It also called getRepos on
every hydrate, including the hot floating-tab poll path that is contractually
free of repo/provider inventory work.

Resolve the inventory lazily and only for keys that parse to a repoId, and keep
`null` (unavailable) distinct from an empty list (all repos really gone). An
absent list now fails open; a known list still prunes as #9343 intended.

Fixes 3 tests that have been failing on main since #9343 landed:
orca-runtime-terminal-retirement (2) and orca-runtime (1).

* refactor(editor): extract the pending-focus effect to clear the max-lines cap

#8083 pushed RichMarkdownEditor.tsx to 404 counted lines against the 400-line
.tsx cap, failing lint for every PR that merges current main. The Explorer
find-focus request is self-contained, so it moves to its own hook.
2026-07-24 13:25:15 -07:00
JinjingandOrca 772081577e Fix fork PR/MR worktree creation race via durable review-head refs (#10429)
* Fix fork PR/MR worktree creation race via durable review-head refs

When creating a fork PR/MR worktree, concurrent `git fetch origin` operations
clobber the shared FETCH_HEAD, causing the wrong commit to be checked out.
Fetch PR/MR heads into dedicated per-review refs (`refs/orca/pull/<N>`,
`refs/orca/merge-requests/<N>`) that persist and isolate each head from other
fetches. Gracefully keep the compare-base when the fetch fails but the local
ref already exists, avoiding silent fallback to the wrong branch on transient
network errors.

* Bound PR/MR head fetches with 60s timeout

Prevent PR/MR creation from hanging when a remote is stalled or
unreachable. Both GitHub and GitLab head fetches now enforce a
60-second timeout, matching the bound used in the create-path
fetch. Durable refs (refs/orca/pull/*, refs/orca/merge-requests/*)
decouple the ref from FETCH_HEAD, preserving legacy client semantics.

* test: align CI expectations with main PowerShell/sparse regressions

PR checks merge into main, which recently changed PowerShell launch args
(cwd restore after profiles) and sparse-checkout detection (require
core.sparseCheckout). Derive PowerShell spawn args from the production
resolver, mock the sparse config flag, reset shared worktree list scan
cache between tests, and stop requiring floating polls to avoid getRepos
hydration.

* Address review follow-ups on durable review-head refs

- Unify PR review-head remote selection: local and SSH GitHub paths share
  resolveGitHubReviewHeadRemote, which prefers the remote mapping to the
  hosting GitHub project (upstream before origin, matching work-item/API
  candidate order) so contributor clones fetch refs/pull from the repo
  that actually hosts the PR.
- Soft-keep durable review heads: when the PR/MR head fetch fails but
  refs/orca/pull/<N> / refs/orca/merge-requests/<iid> still resolves,
  keep the pinned SHA (warn) instead of failing resolve, mirroring the
  compare-base fallback. Extracted shared compare-base soft-keep into
  compare-base-ref-fetch.ts.
- Extract fetchGitLabMergeRequestHeadRef (local + SSH) parallel to the
  GitHub helper; bound its local fetch with the shared 60s timeout.
- Share relay-style fetch validation (positive safe-integer id, remote
  not starting with "-") between relay and local helpers via
  review-head-tracking-ref.ts; move REVIEW_HEAD_FETCH_TIMEOUT_MS there.
- Drop the githubPullRequestHeadLocalRef re-export; resolve head SHAs via
  rev-parse --verify <ref>^{commit}.
- Add GitLab anti-FETCH_HEAD regression test plus durable-head soft-keep
  and remote-selection unit tests.

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

* test: supply live getRepos for terminal-retirement hydrates

Main's headless tab hydrate (#9343) skips worktree keys whose repo is not
in getRepos. Retirement tests that rebuild mobile tabs from a persisted
session now advertise the fixture repo as live so PR Checks merge stays green.

* fix(editor): extract RichMarkdownEditor props to stay under max-lines

Main's SSH external-image wiring (#10323) pushed RichMarkdownEditor.tsx over
the 400-line tsx budget, failing PR Checks lint on every merge into main.
Move the props type into a sibling module so the component stays under the
limit without disabling max-lines.

* Make durable review-head refs remote-identity scoped

Embed remote name + URL hash into refs/orca/pull|merge-requests refs to prevent soft-keep from serving wrong project's PR/MR when FETCH_HEAD is clobbered by concurrent fetch. Fetch functions now return the written ref path (writer-authoritative) so callers rev-parse exactly what was fetched, not re-derive identity. Soft-keep only applies to transient errors (timeout, network); fails hard on missing refs, auth failures, and stale relay. Relay returns localRef so client avoids re-hashing (URL normalization can disagree).

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 12:53:18 -07:00
Brennan Benson d56e2fbbe4 feat(agent-dashboard): choose in-window board or pop-out window (#10243)
* feat(agent-dashboard): choose in-window screen popover or pop-out window

The experimental Agent Dashboard opened only as a separate pop-out
window. Add an "Open as" mode under the experimental toggle so it can
open as an in-window screen popover (new default) or a pop-out window
(prior behavior). The mode row appears only when the feature is on.

- New setting `experimentalAgentDashboardMode: 'in-window' | 'popout'`
  (default in-window); sidebar entry branches on it.
- In-window: AgentDashboardOverlay renders the shared AgentKanbanBoard
  in a near-fullscreen dialog, snapshot built locally via
  useLiveDashboardSnapshot (the pop-out relays over IPC; in-window has
  no relay). Ack/reveal act on the local store — the pop-out IPC
  handlers are gated to the pop-out renderer.
- AgentKanbanBoard gains containerClassName/onAckAgent/onRevealAgent/
  onClose props; defaults preserve the pop-out behavior.
- Extracted AgentDashboardExperimentalSetting to keep ExperimentalPane
  under the max-lines cap.

* fix(agent-dashboard): admit main renderer to terminal-preview IPC for in-window dialog

The terminalPreview:* handlers gated every channel to the pop-out
renderer, so the in-window overlay's terminal dialog (running in the
main renderer) got { snapshot: null } from connect and falsely showed
"No live terminal — this agent's pane has closed." for live agents.
Accept the trusted UI renderer too — it already has full PTY access
through the regular terminal channels, so this adds no reach.

* fix(agent-dashboard): sync locale catalogs for new mode/close keys

verify:localization-catalog (part of lint CI) fails when en.json keys are
missing from the other locale catalogs; run sync:localization-catalog so
the six new agent-dashboard keys exist everywhere (English fallback text;
translated copy remains the documented follow-up).

* feat(agent-dashboard): present in-window mode as a companion board sheet

The in-window dashboard now uses the same non-modal left sheet as the
workspace kanban board — anchored to the sidebar edge, chrome/status-bar
bounds, sidebar stays interactive — instead of a near-fullscreen modal
dialog. Both companion boards are mutually exclusive; the sidebar entry
toggles the drawer. Removes the modal focus-restore timing coupling on
reveal.

* fix(agent-dashboard): ignore Radix dismiss requests like the workspace board

Non-modal Radix layers also request dismissal for interactions the drawer's
outside guards cannot classify — focus moving outside carries no pointer
coordinates, and clicks in the status bar / top chrome fall outside the
right-side dismiss band. Forward only open requests from the Sheet, matching
WorkspaceKanbanDrawer, so only the drawer's own escape/outside/close paths
close it.

* fix(agent-dashboard): guard reveal relay and refresh stale mode copy

CodeRabbit review: revealAgent lacked the ?. HMR-skew guard its sibling
ackAgent has (both channels shipped together, so a stale dev preload
lacks both). The es/ja/ko/zh catalogs also still described the dashboard
as pop-out-only in stale English, contradicting the new in-window
default; refreshed to the current English source.

* feat(agent-dashboard): add board settings menu to the in-window header

Mirrors the workspace board's settings gear: an Open as segmented control
in the board header so the mode is changeable without opening Settings.
Switching to pop-out hands the surface over (closes the drawer, opens the
window) instead of leaving a board the setting says should be a window.
In-window only via an optional headerActions slot - the pop-out renderer
has no store to drive it.

* fix(agent-dashboard): reset the settings-menu flag when the drawer closes

The pop-out hand-off closes the sheet while the menu is still open, so the
menu unmounts without Radix reporting onOpenChange(false). The stale
menuOpen=true then blocked outside-dismiss permanently on the next
in-window open. Mirror closeWorkspaceBoard by resetting the flag in close.

* fix(agent-dashboard): reset the menu flag on store-driven drawer closes

Cmd+B sidebar collapse and the workspace-board exclusivity effect close
the drawer via setAgentDashboardDrawerOpen directly, bypassing close();
a settings menu open at that moment unmounted without Radix reporting
onOpenChange(false), leaving menuOpen stuck true and outside-dismiss
disabled on the next open. Sync the flag to the open state so every
close path resets it.
2026-07-24 12:48:18 -07:00
Jinjing b71f71904f Stack head identity above base ref in compare row (#10430)
Stacked layout (head / → base) lets long branch names fit narrow sidebars
without truncating either line. Also show head-only identity when compare base
isn't configured, and improve upstream vs compare target distinction in stats.
2026-07-24 12:26:14 -07:00
Jinwoo HongandOrcaWin 01929fde40 fix(ssh): open external host images from terminal links (#10323)
* fix(ssh): open external host images from terminal links

* fix(ssh): keep external file ownership host-scoped

* fix(ssh): reject blank external file owners

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-24 12:05:38 -07:00
Mark Xian 1bd36ce04b fix(github): align PR file diff order (#10081) 2026-07-24 02:10:30 -07:00
Neil 1367094bbc fix(terminal): flush URL hover tooltip to pane bottom-left corner (#10351)
Move link-tooltip chrome into terminal.css so offsets cannot drift via
inline styles, and square the bottom-left corner so the hover preview
sits flush against the pane edge (Ghostty-style).
2026-07-24 01:50:55 -07:00
Kaynan Sampaio de Camargoandkaynan d50ea090cf fix(terminal): reveal Markdown links at target lines (#9518)
* fix(terminal): reveal Markdown links at target lines

* fix(terminal): scope line reveals to opened tabs

---------

Co-authored-by: kaynan <kaynan.camargo@terceiro-sky.com.br>
2026-07-24 00:39:37 -07:00
YoelandCursor d1ccfcff40 fix(settings): normalize and validate branch prefixes (#7772)
* Normalize branch prefixes and flag invalid ones in settings

A custom branch prefix ending in a slash (e.g. "team/") produced a
double-slashed branch name like "team//feature" that git rejects, and
the raw check-ref-format error gave no hint that the prefix caused it.

- Normalize the configured prefix (trim whitespace, strip leading/
  trailing and duplicate slashes) in the shared branch-name builder so
  the common trailing-slash case just works, for local and SSH worktrees.
- Validate the prefix on the worktree-create path (computeValidatedBranchName)
  so a genuinely invalid prefix fails fast with a clear
  "update it in Settings -> Git" message instead of an opaque git error.
- Add a live BranchPrefixFeedback under the Branch Prefix setting: previews
  the resulting branch name, warns on invalid characters, and notes when a
  prefix collapses to none.
- Keep the background first-work rename on the non-throwing builder since the
  prefix is already validated at create time.

* Keep caret in place when editing the branch prefix

The custom branch prefix input was directly controlled by settings, but
updateSettings persists through an async IPC round-trip, so the value
updated a tick late and React re-assigned it, snapping the caret to the
end on mid-string edits. Drive the input from a local draft and only
adopt genuine external settings changes so the caret stays put (and fast
typing survives slow SSH round-trips).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Return ReactNode from BranchPrefixFeedback

JSX.Element needlessly excludes null/string/number returns; ReactNode
keeps the component's return type from over-constraining future changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:39:32 -07:00
NeilandOrca eea1577ddd fix(dashboard): close closed-agent retention race (#9008) (#10334)
Co-authored-by: Orca <help@stably.ai>
2026-07-24 00:39:06 -07:00
Brennan Benson ec04827b37 test(session): guard against workspace-reopen tab fork-bomb (STA-1111) (#10057)
* test(session): guard against workspace-reopen tab fork-bomb (STA-1111)

Relates to STA-1111 (already fixed on main by #6945).

The runaway-tab-on-reopen bug was fixed by #6945 (activeOrQueuedResumeClaimsProviderSession dedup guard) hours before the ticket was filed. Verified: disabling that guard makes tab count climb 1->2->3->4 across reopens; restoring it holds at 1. This adds a revert-sensitive regression test covering the created-agent and sleeping-resume reopen paths so it cannot regress.

Test plan: 2 new tests pass; fail if the #6945 guard is removed.

* test(session): isolate automatic resume replay coverage

* test(session): assert resumed tab identity stays stable
2026-07-24 00:36:50 -07:00