Commit Graph

7405 Commits

Author SHA1 Message Date
Neil f48cb78646
perf(store): index worktrees and tabs once per hydration loop (#10891)
* perf(store): index worktrees and tabs once per hydration loop

Four sites in hydrateWorkspaceSession and reconnectPersistedTerminals rebuilt a
flattened array inside a loop and linearly searched it:

  Object.values(worktreesByRepo).flat().find((e) => e.id === worktreeId)

That is O(rows x ids) for O(rows + ids) distinct work. Build one first-wins index
per loop instead. Neither loop sets state or awaits, so a single index over the
store snapshot is valid for every iteration.

54.5x at a real 10-repo / 423-worktree session with 188 pending reconnects;
2.1x on a one-repo session. This runs synchronously on renderer cold start and
gates workspaceSessionReady, which blocks terminal pane mounting.

First-wins matters: Array.prototype.find returns the first match, so an index
that overwrote on collision would resolve a different repo for a duplicated
worktree id. Both the tests and the benchmark fixture carry a deliberate
cross-repo duplicate so that difference is observable.

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

* test(store): count the generated worktree rows instead of multiplying

Review feedback: the table printed repoCount * worktreesPerRepo, which misses the
duplicate id makeStore injects for multi-repo cases (that duplicate is what makes
first-wins observable). Count the generated map, and say plainly that the fixtures
are synthetic at real-world scale rather than a replay of a real session.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:09:31 -07:00
Neil 1fd0f731fc
fix(automations): bind agent terminal output before publishing, and launch SSH folder workspaces on their own host (#10818)
* fix(terminal): bind the agent PTY before the run tab is ever published

Why: launchAgentBackgroundSession created the hidden run tab synchronously and
only then awaited the agent spawn, so the store briefly held a tab with
ptyId: null. Terminal.tsx re-renders on that write, and for an already-visited
worktree the tab can neither cold-park nor defer — a TerminalPane mounts, finds
no adopt candidate, and starts a fresh default shell. When the agent PTY
finally resolves it is rebound in state but the mounted pane still holds the
shell, so the user sees a bare prompt and the agent PTY is orphaned (#2989).

Reserve the tab id before the spawn and create the tab already bound to the
live PTY, with no await in between.

* fix(terminal): resolve folder workspaces, and fail closed on a tab-id collision

allWorktrees() reads only worktreesByRepo, so every folder workspace looked
absent and its automation died at resolution. getKnownWorktreeById covers both.
On a reserved-id collision, re-keying the tab could never work: ORCA_TAB_ID and
ORCA_PANE_KEY are already baked into the spawned process, so routing and hook
identity would permanently disagree. Retire the launch instead.

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

* test(automations): split the background-session suite so it stays under the 800-line cap

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

* fix(automations): route folder-workspace agent launches to their owning SSH host

Review of the bind-before-publish fix surfaced a second defect on the path it
newly makes reachable. A folder workspace has no repo row — its synthetic
repoId is `folder-workspace:<groupId>` — so `repos.find(...)` returns null and
every repo-derived launch input silently degraded to a local default:
connectionId null, platform CLIENT_PLATFORM, isRemote false. The automation
then spawned on the user's machine with a cwd that only exists on the SSH host.

Before this branch that path threw before reaching the spawn, so the bug was
latent; making folder-workspace automations work is what exposes it.

Host resolution now goes through resolveAgentBackgroundLaunchHost, which falls
back to the workspace scope (the same getFolderWorkspaceConnectionId that
ordinary terminal creation uses) when there is no repo. Ambiguous scopes —
mixed local/remote children — still resolve to a local launch rather than
guessing a host.

Extracted to its own module rather than inlined: the added branch pushed
launch-agent-background-session.ts over the 300-line oxlint cap, and a
max-lines disable is forbidden.

Tests: an SSH folder workspace must spawn with its connectionId and remote cwd;
a local one must stay local. Reverting the fallback fails the first and leaves
the rest green.

* fix(automations): close folder dispatch and adoption races

Read live state before adopting a reserved tab so a collision that lands during the spawn is retired instead of re-keyed. Route persisted folder-workspace dispatch through its ambiguity-aware owner, including SSH auth, remote trust, quiet-shell fallback, and WSL shell selection.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:03:58 -07:00
Neil 974447175f
feat(terminal): add manual parking developer action (#11016) 2026-07-27 17:00:50 -07:00
Neil 79ec57d045
perf(relay): keep the PTY replay window as chunks, not a re-sliced string (#10900)
* perf(relay): keep the PTY replay window as chunks, not a re-sliced string

appendReplayBuffer did `buffered += data` then `buffered.slice(-REPLAY_BUFFER_MAX)`
once over the 100KB cap. It runs per raw node-pty emission -- before batching --
so once a PTY saturates the window (which a long-lived shell does almost
immediately) every subsequent chunk copied the whole 100KB.

Reuse RecentPtyOutputBuffer, which already solved this shape in the main process:
keep chunks, drop from the head, defer the join to read(). The relay's three
readers are attach, adopt, and revive only.

66-205x on the append path, per PTY, on the user's SSH host.

RecentPtyOutputBuffer's limit is now configurable, because the relay retains
100KB where the main process retains 64KB. One arithmetic branch still used the
hardcoded constant after that change and silently under-retained (100,800 of
102,400 code units); the equivalence tests caught it before it shipped, and the
suite now pins the configured limit directly.

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

* test(relay): exercise a real surrogate split; drop eval from the benchmark

Review feedback, both valid:

- The surrogate test never split a pair. The cap is even and a pair is two code
  units, so an emoji run alone always cuts on a pair boundary. A trailing single
  unit shifts the cut mid-pair, leaving a dangling low surrogate (0xDE00) --
  asserted directly now, with the boundary-aligned case kept as its own test.
- Parse REPLAY_BUFFER_MAX as a product instead of eval(). The regex already
  admits only digits, spaces and `*`, and eval tripped Biome's noGlobalEval
  regardless of the eslint suppression.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 16:40:59 -07:00
Neil 7f3c95a585
fix(git-history): stop reading the option marker as the resolved ref name (#10906)
`rev-parse --verify` swallows --end-of-options, but --symbolic-full-name
deliberately echoes it -- on every git version tested, 2.25 through 2.49:

  $ git rev-parse --symbolic-full-name --end-of-options feature
  --end-of-options
  refs/heads/feature

resolveSymbolicFullName took the first non-empty line, so it returned the literal
string "--end-of-options" instead of the ref. That value flows into
gitHistoryRefFromFullName, matches none of the refs/heads, refs/remotes, or
refs/tags prefixes, and every named branch and tag in git history was silently
categorized as a plain commit with a garbage id.

Skip the marker line. Version-independent bug; no test covered it.

Also pins git's echo behavior in the real-binary compatibility suite, so if a
future git stops emitting the marker the reason for the skip gets re-read rather
than the assumption quietly rotting.

Co-authored-by: Orca <help@stably.ai>
2026-07-27 16:40:56 -07:00
Neil b31e66ed48
fix(browser): survive a transient Windows lock during cookie import (#10697)
* fix(browser): retry a transient Windows lock on the Chromium cookie snapshot copy

The snapshot attempt loop only reacts to a `false` return, so a throwing
copy escaped it entirely. On Windows, AV/EDR briefly opens a file literally
named "Cookies" with FILE_SHARE_NONE, which turns an otherwise-fine
copyFileSync into `EBUSY errno -4082 syscall=copyfile` and aborts the whole
import (#9355).

Route the main-database copy through the existing
`copyFileWithWindowsRetry`, already used for the same AV window in #1507.
It is a no-op off Windows (maxAttempts=1), so POSIX still fails fast and
ENOENT is never retried on any platform.

* fix(browser): degrade the cookie-import staging DB instead of aborting the import

Staging exists only to back the cold-restart replay for cookies the
in-memory path rejects, but three points in it were fatal to the whole
import (#9355):

- the staging copy from the live partition DB — also a file named
  "Cookies", so the same AV/EDR handle blocks it;
- opening/PRAGMA-ing that staged file;
- both were unguarded, so a throw escaped to the catch-all and returned
  `ok: false` even when every cookie could load in memory.

Each is now non-fatal and diagnosed. Two invariants keep the degraded path
honest: `imported++` moved out of the staging insert so the summary counts
importable cookies rather than staged rows, and `setPendingCookieImport` is
never called when staging is unavailable — registering a path that was never
written would replay a missing or partial DB over the live partition on cold
start.

* fix(browser): stop a degraded cookie import from replaying a stale staged database

Review round 1 found the staging-degradation path could leave an older
pendingCookieImports entry registered while the import rewrote the live
session, so the next cold start replayed the stale DB over fresh cookies.

- add clearPendingCookieImport so a degraded import retires the old entry
- degrade staging on BEGIN/insert/COMMIT failure instead of aborting the import
- discard the staged cookie copy on every non-registering path
- pin the stagingAvailable guard, which previously survived mutation

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

* fix(browser): report a degraded cookie import honestly instead of as a clean success

Making the staging failure non-fatal introduced a silent-loss path: the import
clears the live jar before loading cookies, so when staging was unavailable AND
Electron rejected cookies, the user lost their old jar, got none of the new
cookies, and still saw "Imported N cookies".

Adds an optional `warning` to the import summary, set only on that degraded
branch, and routes every cookie-import toast through a shared emitter that
raises a warning toast instead of an unqualified success.

Also closes three test holes found in review:
- clearPendingCookieImport had no direct tests; deleting the wrong partition key
  survived all 10 registry persistence tests. Now covered, mutation killed.
- The staging-insert-failure test was vacuous (memoryFailed === 0 suppressed
  registration on its own). It now forces a memory failure.
- No test pinned the success-path clear; removing it survived. Now covered.

* fix(github): resolve owner/repo through SSH Host aliases (#10284) (#10361)

* fix(github): resolve owner/repo through SSH Host aliases (#10284)

Expand OpenSSH Host → HostName via ssh -G before classifying github.com
identity so PR merge works when origin is git@alias:owner/repo.git.
Transport URLs stay unchanged so IdentityFile selection is preserved.
Do not long-negative-cache indeterminate ssh -G failures.

* fix(github): harden SSH alias resolution

* Update README downloads badge

* fix(persistence): fsync state writes so a rename is actually durable (#10631)

* fix(persistence): fsync state writes so a rename is actually durable

`Store` wrote `orca-data.json` to a temp file and renamed it. rename() is
atomic for readers but says nothing about durability: without an fsync the
directory entry can reach disk before the data does. After power loss or a
hard crash the file can come back holding the previous state or, worse,
zero bytes — and `JSON.parse('')` throws, so an empty file takes the
full corrupt-file path rather than degrading.

This is the same empty-file symptom as #1158 from a different cause. That
issue fixed a logic path that persisted empty state and added the .bak ring
as a safety net; the ring also catches this, which is why it went unnoticed.
Recovery costs up to an hour of tabs/layouts/session state (backups are
throttled to >=1h spacing), and a user in their first hour has no backup
slot yet, so they land on defaults indistinguishable from a fresh install.

Both write paths now fsync the temp file *before* the rename, then fsync
the containing directory. Directory fsync is best-effort by design: Windows
cannot open a directory for fsync and some filesystems reject it, so it is
swallowed. The file fsync is the load-bearing part and works everywhere.

Measured cost on a 3 MB payload: ~0.2 ms per write, against a 1s debounce.
The async path does not block the main thread.

The syscall-order test mocks `node:fs` and counts fsync targets at the
module boundary, asserting ['file', 'directory'] — proving the ordering
rather than inferring it from reading the implementation, since a fsync
after the rename would still pass every content assertion.

* test(persistence): make the syscall proof platform-aware and actually prove the order

Two problems, both found from CodeRabbit's Windows observation.

The assertion hardcoded ['file', 'directory']. Directory fsync is
deliberately best-effort — Windows cannot open a directory for fsync and
some filesystems reject it — so on Windows the helper swallows the failure,
only the file fsync is observed, and the test fails. The expectation now
probes the real platform instead of assuming, keeping the guarantee tight
where directory fsync works rather than dropping it everywhere.

Worse, the test did not prove what its name claimed. Moving the fsync to
*after* the rename still passes: the file is fsynced either way, and only
fsyncs were recorded, so the correct and broken orders produced an identical
log. Mutation-testing the "before rename" claim is what surfaced this — the
mutation passed.

The rename is now recorded in the same sequence, since it is the boundary
the ordering is defined against. Re-running the same mutation fails, so the
ordering claim is now backed by the test rather than asserted in a comment.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(window): stop burning macOS GPU on an invisible blur effect (#8482) (#10682)

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

* feat(sidebar): distinguish and filter CLI-created workspaces (#10712)

* perf(relay): stop snapshotting the whole pending-PTY map every drain tick (#10670)

* perf(sidebar): share one worktree-keyed agent orchestration index (#10678)

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

* fix(mobile): recover unreliable relay connections (#10709)

* fix(mobile): recover unreliable relay connections

* test(mobile): use valid raster preview fixtures

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* release: v1.4.157-rc.0

* docs(relay): trim the pending-output drain comment (#10714)

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

* fix(history): quarantine unreadable recovery generations. (#10713)

* fix(history): quarantine unreadable recovery files

* fix(history): preserve mixed recovery generations

* fix(history): reanchor reconciled live sessions

* fix(history): serialize final checkpoint queue

* fix(history): drain sleep shutdowns before disconnect

* fix(history): restore legacy wide sessions

* fix(history): preserve malformed mixed logs

* fix(history): preserve malformed log tails

* refactor tests to reduce file size

* refactor(history-recovery): extract freeze helper and improve test robus

- Extract takeRecoveryFreeze to eliminate duplicated freeze-and-clear pattern across five call sites
- Skip permission-mode tests on root CI containers (chmod 0o500 doesn't block root writes)
- Replace fixed sleep with deterministic wait for queued exclusive checkpoints
- Distinguish ENOENT (missing) from corrupt in history metadata reads
- Add ceiling-dimension restore test and torn-tail exclusion assertion
- Wrap chmod operations in try/finally to prevent leaked permissions from masking test failures
- Add .catch() to checkpoint promise to prevent unhandled rejections from finally re-throws

* test(history-recovery): consolidate checkpoint assertions

Wait for both the checkpoint call and set clear atomically to avoid
a timing race where the spy fires before the set is cleared.

* fix(persistence): unbreak main by expecting the new 'cli' card property in fresh defaults (#10722)

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

* fix(sidebar): stop worktree drag from spazzing when cards resize mid-drag (#10725)

* fix(sidebar): make Cmd/Ctrl+1-9 match the rendered card order when the sidebar is closed (#10693)

* fix(native-chat): wrap question text and option descriptions instead of truncating (#10025)

* fix(mobile): pop to home when leaving a host so the back chevron animates backward (#9723)

* fix(i18n/zh): correct technical literals and clear sense errors (#10048)

* feat(speech): add Korean streaming zipformer STT model (#9893)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(source-control): add copy relative path (#9018)

* fix(gitlab): stop refresh button overlapping dialog close X (#9445)

* fix(gitlab): stop refresh button overlapping dialog close X

The GitLab item dialog's SheetContent renders its own close (X) at
absolute right-4, but the header refresh button sat at the header's
px-5 right padding and overlapped it. Reserve pr-10 on the header so
the refresh button clears the close X, and lift it -mt-1.5 so its icon
aligns with the close X on the same line.

* fix(gitlab): integrate sheet controls into header

---------

Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* fix(sidebar): raise selected workspace contrast in dark mode (#8321)

* feat(editor): toggle Word Wrap from file tab actions and Alt+Z (#10086)

* feat(editor): toggle Word Wrap from file tab actions and Alt+Z

Long single-line and structured files wrap by default and misalign.
Surface Word Wrap on the editor more-actions menu for normal file tabs
(diff already had it) and add editor.toggleWordWrap (Alt+Z) so users can
unwrap without opening Settings.

Closes #9974

* fix(editor): toggle diffWordWrap for diff surfaces on Alt+Z

CodeRabbit: Alt+Z previously always flipped editorWordWrap, leaving
diff panes out of sync with the markdown actions menu.

* test(editor): verify word wrap shortcut routing

Cover editor/diff setting callbacks and the cross-platform Alt+Z binding.

---------

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

* fix(repo-icon): detect Tauri and WebP icons (#7942)

Expand repository icon auto-detection to conventional Tauri and public/icon paths with PNG/WebP magic and dimension validation. Bound SSH probing while preserving candidate priority and PNG-only user uploads; SVG remains rejected.

* Add bulk tab closing to mobile long-press sheets (Close Others / Left / Right) and complete the desktop tab context menus (#9323)

* Add Close Tabs to the Left and complete Close Others across tab menus and mobile long-press sheets

* Fold the per-sheet Close action into the bulk-close module (session route max-lines)

* fix(mobile): preserve pinned tabs during bulk close

---------

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

* feat: implement Cmd+Enter as commit shortcut in Source Control (#9773)

* feat: implement Cmd+Enter as commit shortcut in Source Control

* test: add unit tests for commit shortcut and tooltip formatting

* fix: address review feedback on modifier keys and test coverage

* test: split mac and windows/linux shortcut and keydown tests

---------

Co-authored-by: Andres Van Reepingen <andres.vanreepingen@datacamp.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* Add SenseVoice speech-to-text model (Korean/Japanese support) (#7436)

* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(settings): show a way back to local accounts when a remote server owns provider-account scope (#8188)

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

* feat(speech): add Parakeet TDT-CTC 0.6B JA voice model (#8207)

* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(speech): add Parakeet TDT-CTC 0.6B JA to the speech model catalog

* test(speech): cover stt-worker-model-config file resolution incl. single-file models

* feat(speech): decode Parakeet TDT-CTC JA via sherpa-onnx nemoCtc offline recognizer

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: xsacdw <xsacdw@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>

* fix(terminal): stop switch bold flash and Windows lag (#10692)

* fix(terminal): stop bold flash on worktree switch

Worktree hide disposes WebGL and falls back to xterm's DOM renderer.
On reveal, resume ran after paint and flushed backlog against DOM first,
so the first frame used heavier CSS-AA glyphs before WebGL settled.

Resume in useLayoutEffect and reattach WebGL before backlog flush so the
first painted frame stays on the GPU path. No cold-park policy change.

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

* fix(terminal): fit WebGL grid before backlog flush on resume

Adversarial review: resume-before-flush alone wrote TUI backlog onto the
transient DOM↔WebGL one-column-off metrics window. Order is now
resume → fitAllRevealedPanes → flush on heavy reveal and window wake.

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

* fix(terminal): latch viewport intent before WebGL wake resume

Adversarial review: wake path synced intents after resume/fit, which can
re-latch a pinned viewport as followOutput. Capture before reattach and
drop the post-resume re-sync on heavy reveal (outer path already latched).

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

* fix(terminal): complete visibility bookkeeping before PaneManager exists

useLayoutEffect runs before the passive lifecycle creates PaneManager, so
the mount-visible path never set hasCompletedVisibleResume. The first
intra-worktree hide then wrongly suspended WebGL. Bookkeep completion
even when managerRef is still null (extracted helper for max-lines).

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

* fix(terminal): re-sync pin geometry after resume backlog flush

Keep the pre-resume intent latch (reattach must not re-latch pins as
followOutput), then re-sync after flush with preservePinnedAtBottom so
scrollback trim updates absolute pin lines before enforce.

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

* fix(terminal): drop same-tick post-flush intent re-sync

flushTerminalOutput only queues terminal.write and returns before parse, so
a same-tick re-sync read pre-parse resume/fit geometry and could overwrite
pre-resume pins. Keep pre-resume latch + enforce only.

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

* fix(test): expect default worktree card properties to include cli

#10712 added 'cli' to DEFAULT_WORKTREE_CARD_PROPERTIES, but the fresh
default-profile assertion still omitted it and fails verify.

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

* perf(terminal): retain Windows WebGL across worktree hides

* perf(terminal): bound retained WebGL contexts

* fix(terminal): harden retained WebGL lifecycle

* fix(terminal): preserve healthy WebGL on wake

* fix(terminal): preserve reveal recovery ordering

---------

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

* fix(mobile): clear native-chat composer optimistically at send time (#10226)

* fix(mobile): clear native-chat composer optimistically at send time

Over relay the send RPC round trip is visible and a lost ack (or a
relay/direct cutover) could strand the sent prompt in the composer
forever: the unconfirmed-send deadline dropped its tracking entry, so a
late transcript echo could never clear the draft.

Clear the draft at send time and restore it only on a definite
rejection. holdUnconfirmedSend now only manages the delivery-unconfirmed
notice; it no longer touches drafts.

* fix(mobile): isolate question answers from composer drafts

* fix(cli): bound orchestration ask timeouts (#10689)

* fix(cli): bound orchestration ask timeouts

* fix(cli): harden remote timeout boundaries

* fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail (#10729)

* fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail

Windows renderer OOM F0BKR84AHEH (0xE0000008) arrived with a 30-entry
breadcrumb ring in which two `terminal_safe_fit_retry_exhausted` bursts
consumed 26-90% of the slots. Every hidden pane is `display:none` -> 0x0 ->
unmeasurable, so one post-reload reattach wave exhausts the retry budget once
per mounted pane inside ~60ms.

The bursts were also uninterpretable: `pane.id` restarts at 1 per PaneManager
and there is one manager per tab, so 34 identical `paneId: 1` crumbs cannot
distinguish one pane looping from 34 panes firing once. Coalesce the crumb by
name and carry the live-pane census on the payload instead, so the count
survives without costing 34 ring slots.

Same treatment for WebGL diagnostics, which were worse off: context-loss and
atlas-reset crumbs only reached a DevTools-only ring (`window.n()`), so a
renderer that dies takes them with it. That bundle had three GPU-process deaths
in the 65s before the renderer OOM and zero WebGL evidence - absence of
instrumentation, not absence of the event. Mirror them into the crash report,
coalesced per kind so a routine atlas reset cannot mask a context loss.

Evidence-only: no behavior, rendering, or lifecycle path changes.

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

* perf(pane-manager): count panes without materializing public views

The census runs on the crash path; getPanes() allocates a full ManagedPane
projection per pane just to read .length.

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

* test(crash-reporting): pin the fit-retry burst against the 30-entry ring

Reproduces the F0BKR84AHEH ring loss directly: 10 pre-crash crumbs plus a
34-crumb per-pane burst. Uncoalesced, the burst takes all 30 slots and zero
pre-crash crumbs survive; coalesced, it takes one slot, all 10 survive, and
the pane count rides on the payload instead of on the crumb multiplicity.

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

* fix(crash-reporting): name the WebGL census the same as the fit-retry census

The context-loss crumb spread getLivePaneCensus() raw, so one ring described
one measurement two ways: managers/panes here, livePanes/livePaneManagers on
the fit crumb. Spreading also meant renaming the census return keys would
silently reshape the crumb. Name the fields at the call site and pin them.

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

* fix(crash-reporting): keep a hot coalesce key from being the first LRU eviction

The suppression path returned before the delete-then-set that re-anchors
recency, so a key hit continuously never moved from its original insertion
slot and became the first eviction candidate — the inverse of the LRU's
stated intent.

`renderer_error` keys carry message+stack identity, so one noisy render loop
mints unbounded distinct keys. Within a single 30s window that churn evicted
the `terminal_safe_fit_retry_exhausted` key mid-burst, un-suppressing it and
re-arming the exact ring flush the coalescing exists to prevent.

Re-anchor position only; `recordedAt` is left alone so the suppression window
still expires on schedule rather than renewing on every hit.

Found while adversarially probing the LRU claim in #10729's own description,
which asserted these keys "cannot evict live keys".

* fix(crash-reporting): report the newest census of a coalesced burst

The suppression path wrote nothing to the ring, so a coalesced burst froze
its FIRST event. Panes mount progressively, so pane 1 exhausting alone
legitimately measures livePanes: 1 -- and the 33 later crumbs, each carrying
a truer census, were dropped. A 34-pane wave was recorded as `livePanes: 1`
with no count: the exact "one pane looping" misread that coalescing by name
was introduced to prevent.

The existing burst test missed this because it fed a constant census on
every crumb, making frozen-first and newest-wins indistinguishable.

Stash the newest payload and fold it into the ring entry the key already
owns: still one slot, now reading livePanes: 34 + suppressedSinceLast: 33.
Resolution is deferred to snapshot time -- sanitizing per suppressed hit of
a 1459/min crash loop measured 2194 ns/op vs 185 ns/op deferred.

Two follow-on defects fixed alongside: an expiring key dropped its pending
payload (it loses its only handle on the ring entry), and resolving the
re-emitting key's own old slot double-counted a burst.

---------

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

* fix(speech): download verified model artifacts directly (#10735)

* perf(renderer): give owner-routed settings a stable identity (#10743)

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

* perf(agent-status): validate hook payloads without the JSON round trip (#10752)

* fix(gpu-fallback): make the crash window rolling, not launch-anchored (#10707)

* fix(gpu-fallback): make the crash window rolling, not launch-anchored

Software-rendering fallback only ever considered GPU child crashes in the
first 30s after launch: `if (msSinceLaunch > this.windowMs) return`. Session
12e6ee64 crashed the GPU child 4 times (242s / 920s / 926s / 946s since
launch). The last three span 26.0s — inside windowMs, exactly threshold — but
every one was rejected because the burst began 920s in. The renderer died of
process OOM (0xE0000008) 39s later.

GPU work is demand-driven, so the first heavy compositing often happens
minutes into a session. What distinguishes a broken driver from normal
Chromium churn is that the crashes *cluster*, not when the cluster starts.

Keep a sorted array of recent crash times pruned to windowMs behind the
newest, and engage when the count reaches threshold.

Measured against real field telemetry (341 distinct win32 launches with >=1
GPU crash, from process_gone_suppressed breadcrumb trails): the rolling
window engages on 2/341 launches (0.59%), one of which is 12e6ee64. Max GPU
crashes in any single launch is 4. The closest non-firing sequence
([0, 29531, 55136, 74178] — consecutive gaps that each fit the window but
never put 3 inside it) is pinned as a regression test.

Also destroy the Windows tray before app.exit(0) on this path, matching the
app:relaunch IPC handler — app.exit skips before-quit, and this can now fire
deep into a session rather than only in the first 30s.

Mutation-tested: 6 mutants (launch-anchored gate, dropped pruning, dropped
monotonic clamp, cutoff </<=, threshold >=/>, dropped engaged latch), all
killed by the suite.

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

* fix(gpu-fallback): ask before restarting

---------

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

* feat(sidebar): add a filter to hide detached-HEAD workspaces (#10786)

Adds "Hide detached HEAD" alongside the existing sidebar filters, wired
through the same pipeline as Hide CLI-created: sidebar list, Cmd+J
empty-query list, workspace board, active-filter badges, Clear/Reset
Filters, and persisted UI state.

The predicate reuses getWorktreeGitIdentityDisplay so the filter targets
exactly what the card renders a Detached HEAD badge for. Requiring a real
head (not just an empty branch) keeps folder workspaces and
SSH-synthesized rows — which carry both empty — out of the filter.

Activating a hidden detached workspace clears the filter, matching the
existing reveal escape hatch for automation- and CLI-created workspaces.

Splits the filter-state describes out of visible-worktrees.test.ts into
sidebar-filter-state.test.ts to stay under the max-lines budget.

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

* feat(daemon): add daemon_lifecycle replaced/retired telemetry event (#10058)

* feat(daemon): add daemon_lifecycle replaced/retired telemetry event

Implements STA-2376.

Adds track('daemon_lifecycle', {transition, reason, live_session_count_bucket, version_skew?}) covering 'replaced' (unhealthy_resolver / stale_bundle / different_app_path / failed_health_check at daemon-init launcher sites) and 'retired' (died_respawn at the adapter respawn closures). Enum-only + .strict() + bucketed counts keep paths, versions, and raw counts off the wire; preserve-path transitions emit nothing. Cross-platform and SSH-safe; no-op in non-official builds.

Test plan: affected vitest (158) green; typecheck/lint clean except pre-existing unrelated failures.

* fix(daemon): prevent false lifecycle telemetry

* test(daemon): restore once-ness on respawn reason assertions

Keep STA-2376 reason checks without dropping concurrent-respawn
coalescing coverage that prevents double died_respawn telemetry.

* fix(daemon): emit replaced telemetry on runtime unhealthy_resolver respawn

CodeRabbit: adapter-driven macOS resolver replacements forked a new daemon
without a lifecycle event. Emit trackDaemonReplaced (not retired) so field
diagnosis of #7936 covers the runtime path without mislabeling it as death.

* fix(daemon): stop double-counting resolver replaces; drop redundant version_skew

Three telemetry-correctness fixes to the STA-2376 daemon_lifecycle event.

1. The runtime macOS resolver respawn double-counted. doRespawn() disconnects
   but never kills the daemon, so the ensureRunning() that follows re-enters
   createOutOfProcessLauncher, which re-detects healthy + resolver-unhealthy +
   0 sessions and emits the replace itself. The closure emitted a second one.
   It also emitted before the outcome was known, so a resolver that recovered
   mid-flight (or a session appearing) left a 'replaced' on the wire for a
   daemon the launcher went on to preserve. The launcher's emit is gated on a
   confirmed kill, so it is the correct sole emitter; this reverts the emit
   added in 1e60ca87a4. The reason plumbing stays -- it is what keeps a
   resolver respawn from being mislabelled died_respawn.

2. version_skew carried no information and lied to one cohort. It was present
   iff reason === 'stale_bundle' and always true, so it was a deterministic
   function of reason. isDaemonStaleForCurrentBundle also returns true when the
   pid file has appVersion: null -- a replace-once heuristic for pre-marker
   builds, where no version comparison happened at all -- so the field asserted
   skew for exactly the upgrade cohort the event exists to illuminate. Dropped
   from the schema, emitter, and call site, along with the dead branching.

3. track() is now failure-isolated in both emitters. Both call sites sit on the
   daemon launch/respawn path, where a throw costs the user every terminal.

Tests: once-ness (toHaveBeenCalledTimes) on every emit assertion -- the old
toHaveBeenCalledWith-only assertions passed under a doubled call; a regression
guard that the resolver respawn closure stays silent; and a throwing-client
test. Note the unit tests mock DaemonSpawner and never invoke the launcher, so
no test could observe the double-emit; the once-ness assertions bound each
emitter within its own seam.

Known limitation, unchanged: a wedged-but-alive daemon (#8689) can still report
died_respawn from the adapter and failed_health_check from the launcher -- the
app cannot distinguish wedged from dead at that point.

* fix(daemon): attribute the runtime resolver replace so it is not lost

Round-2 review found the previous commit over-corrected. Removing the emit
from the respawn closures was right about the premature emit but wrong about
where the event would come from instead.

doRespawn() does not kill the daemon, but it does drop its only authenticated
client, and that is enough: the last fully-authenticated disconnect sets
retirementRequested, and reevaluateIdleShutdown -> beginIdleShutdown runs with
no grace timer, unlinking the token and PID files. So by the time
ensureRunning() re-enters the launcher, the daemon is already gone --
killStaleDaemon finds no PID file, confirmedReplacement stays false, and the
gate suppresses the emit. Net effect of the previous commit: zero events for a
runtime macOS resolver replacement, the common case.

The double-emit round 1 found was real but narrow: it needs a daemon holding
non-alive sessions, which keeps host.listSessions() non-empty so isIdle() is
false and the daemon survives the disconnect to be killed by the launcher.

Fix: the adapter attributes the reason rather than emitting it, and the launch
it triggers consumes the attribution and reports it. One emit point, exactly
one event, correct reason -- whether the daemon self-retired or survived to be
killed. The attribution is one-shot so a later unrelated launch cannot inherit
it, and it is preferred over the launcher's own inference, which would
otherwise mislabel this as failed_health_check.

Also: suppress died_respawn while a manual restart is in flight. runRestartDaemon
kills the daemon while the outgoing adapter is still live and undisposed, so a
pane respawning on its synthetic exit billed a user action to the crash bucket.

Tests: a regression test that drives the closure and the launcher across the
seam the DaemonSpawner mock normally hides, with killStaleDaemon returning
false to model the self-retired daemon, plus the one-shot assertion. Verified
load-bearing by mutation (neutering the handoff fails it). Also reset
killStaleDaemonMock's implementation in beforeEach -- mockClear left a previous
test's mockResolvedValue in place, which silently disarmed the
confirmedReplacement gate for every test after it.

* fix(daemon): let a proven replacement reason outrank the attributed one

Round-3 review. The attribution was preferred unconditionally, so a launch that
independently proved a different cause reported the wrong one: resolver
unhealthy at the adapter check, daemon survives the client drop (non-alive
sessions keep isIdle() false), resolver recovers milliseconds later, and the
launcher then proves a stale bundle or a different app path and kills on that
basis -- but the event said unhealthy_resolver. A proven kill is grounded in
post-kill truth, so it now wins; the attribution covers only what the gate
cannot see, a daemon that self-retired leaving nothing to kill and no reason to
infer. Every other case is unchanged: self-retired still reports
unhealthy_resolver, and the surviving-daemon case reaches the same reason
through the launcher's own inference.

Also pins the invariant that makes the module-scoped one-shot safe -- the write
reaches the launcher with no await in between -- at both the write and the
consume, since the DaemonSpawner mock lets tests drive the two halves with an
arbitrary gap and would not catch an await being introduced.

Corrects the beforeEach comment from the previous commit: there was never a
plain mockResolvedValue on killStaleDaemonMock in this file, so it did not
silently disarm the gate for later tests. mockReset is still right -- it drops
an unconsumed *Once queue, which mockClear does not.

Tests: a guard that the launcher's proven reason wins over the attribution,
verified load-bearing by mutation (restoring the old ordering fails it).

* fix(daemon): don't let the residual health bucket absorb the resolver reason

Round-4 review caught a regression from the previous commit. Letting any
confirmed kill outrank the attribution was too broad: failed_health_check is
not an identification, it is the residual bucket that also absorbs wedges and
crashes, so preferring it discards the more specific reason the adapter already
established.

This is the likely shape of the incident, not a corner case. The dead macOS
login session that fails the resolver probe also fails the PTY spawn probe, so
checkDaemonHealth returns pty-spawn-unhealthy rather than healthy. The resolver
branch is then never evaluated, and with zero live sessions the degraded
preserve does not apply either, so the launch falls through to
failed_health_check and kills a daemon that survived the adapter's disconnect
(non-alive sessions keep it non-idle). Before this commit that reported
failed_health_check and dropped unhealthy_resolver -- burying the flagship
signal in the catch-all.

Rank by how well each reason is evidenced instead: a confirmed kill that
positively identified the daemon wins, the attribution beats the residual
bucket, and the residual bucket is still reported when there is nothing better.
Round 3's motivating case (a proven stale bundle must not be billed to the
resolver) is unaffected and still covered.

Tests: a guard for the pty-spawn-unhealthy shape, verified load-bearing by
mutation (removing the residual-bucket exclusion fails it).

* test(daemon): cover the confirmation path production actually uses

Round-5 review. No runtime defect, but the three identified reasons were only
ever proven through a mechanism the field never takes.

confirmedReplacement is `(await killStaleDaemon(...)) || cleanupResult.cleaned`,
and the two halves are disjoint in practice. unhealthy_resolver, stale_bundle,
and different_app_path all require health === 'healthy', so cleanup reaches the
daemon over RPC, shuts it down, and unlinks its pid file -- leaving nothing for
killStaleDaemon to find. Production therefore confirms exclusively via
.cleaned. Every test confirmed exclusively via killStaleDaemon, because the
net.connect stub always errors, so cleanup always returned cleaned:false.

The consequence was a silent trapdoor: simplifying the gate to
`confirmedReplacement = await killStaleDaemon(...)` stops all three identified
reasons from ever emitting in the field, and the whole suite stays green.
Confirmed by flipping the killStaleDaemon default to false -- five tests fail,
none of which are meant to be about the kill.

Adds the missing case: cleanup confirms, the kill finds nothing, and
different_app_path is still reported once. Verified load-bearing by mutation --
dropping the .cleaned half now fails this test specifically.

Scoped the new test's mocks to *Once so the identity override cannot leak into
the packaged-bundle test that follows, and hands probeSocketExists back on the
way out since beforeEach only mockClear()s it.

* test(daemon): cover the manual-restart retirement guard

Round-6 review returned land-ready with one note: removing the !restartInFlight
guard from both respawn closures left the whole suite green. That is the same
silent-trapdoor shape the previous commit closed for the .cleaned gate, on a
guard this PR introduced, so it gets the same treatment.

The guard matters because runRestartDaemon tears the daemon down while the
outgoing adapter is still live and undisposed; a pane respawning on its
synthetic exit reaches the death path for what was a user action, and would
bill a manual restart to the crash bucket.

Drives the death from inside the restart's ensureRunning so restartInFlight is
genuinely set by the code under test, rather than asserting against a flag the
test poked itself, and then repeats the respawn after the restart settles to
show the suppression is scoped rather than permanent. Verified load-bearing by
mutation: removing the guard fails this test and nothing else.

* test(daemon): close the two surviving telemetry mutations

Round-7 review returned land-ready with two test-only gaps, both found by
mutation and both the same trapdoor shape as the last two commits.

The manual-restart guard exists in two respawn closures and only the first was
covered. That is the wrong half: the restart installs its own adapter, so from
the second restart onward the copy in runRestartDaemon is the one that actually
runs in the field, and it could be deleted with the suite green. The test now
drives a second restart through the adapter the first one installed.

The privacy-invariant test only built 'replaced' payloads, so .strict() on the
'retired' member was never exercised -- someone adding a field to
trackDaemonRetired after that .strict() was dropped would have reached PostHog
with the test still passing. It now runs the leak set over both transitions,
plus a sanity assertion that each base payload is itself valid so the
rejections prove the leak and not a malformed base.

Both verified load-bearing: neutering the second guard copy, and dropping
.strict() from the retired member, each now fail exactly one test.

* fix(daemon): respawn on PTY write dropped to a dead daemon socket (STA-2373) (#10065)

* fix(daemon): respawn on PTY write dropped to a dead daemon socket (STA-2373)

DaemonPtyAdapter.write() sends keystrokes via fire-and-forget client.notify().
When the daemon dies (retirement, crash, kill), the socket disconnects and the
notify is silently dropped — no rejection reaches withDaemonRetry, so the
dead-endpoint respawn never fires and the attached pane freezes. Only a
request/reply RPC (e.g. createOrAttach from opening a new terminal) detected
the death and forked a replacement.

DaemonClient.notify() now reports delivery; a dropped write to a still-active
session drives the shared respawn coalescer directly (reconnecting the
permanent client before releasing the temporary adoption lease, mirroring
withDaemonRetry's ordering), so the pane self-heals like the createOrAttach
path. Cross-platform + SSH-safe: no platform assumptions, pure adapter logic.

Complements (does not duplicate) #8426, which fixes the adjacent in-daemon bug
where a thrown node-pty write no longer marks the handle dead. That is
daemon-side; this is the app-side dropped-notify that never triggered respawn.

* fix(daemon): restore adapter state after dropped-write respawn

* fix(daemon): recover writes after endpoint respawn

* fix(terminal): remount panes after daemon death

* fix(daemon): recover sibling panes after daemon death, not just the written one

When a daemon dies, its dropped-write respawn only remounted the pane whose
write detected the dead endpoint. Sibling panes (alive at death but not typed
into) were left frozen: stale prompt pixels, silently-dropped input, no live
child, and no recovery even on later keystrokes — the exact STA-2373
frozen-typing symptom on non-triggering panes.

DaemonPtyAdapter now fans a write-unavailable signal out to every active
session when it recovers from a dead endpoint, emitted while the sessions are
still in activeSessionIds so the renderer's liveness gate still reads them
live. pty.ts forwards each to the existing pty:writeUnavailable channel, so all
panes remount + re-attach through the same path the written pane already used.

Adds a revert-sensitive regression test: with two sessions and only one
written after the daemon dies, the sibling must also be signaled to recover.

* revert(format): drop repo-wide oxfmt churn unrelated to STA-2373

A review pass ran `oxfmt --write .` across the tree, pulling seven files
with no bearing on the dead-daemon respawn fix into the PR diff. Restored
to origin/main byte-for-byte so the diff carries only the respawn change.

* fix(daemon): snapshot active sessions before the write-unavailable fan-out

A listener that kills a pane mutates activeSessionIds mid-iteration, which
can skip the very sibling the fan-out exists to reach. Matches the snapshot
fanoutSyntheticExits already takes.

* fix(daemon): re-arm dead-endpoint recovery on every daemon death

The respawn-storm latch was only released once every awaiting session
rebound. Background sessions have no mounted pane, so nothing ever calls
createOrAttach for them and they hold the awaiting set non-empty forever
— latching the fan-out off after the first death and silently making the
whole fix one-shot. Re-arm on the disconnect event instead, which fires
once per established connection, so the storm guard still holds within a
single incident.

* fix(daemon): route the write-unavailable fan-out through the pty router

Main subscribes on the routed provider, and DaemonPtyRouter is the live
localProvider whenever a legacy daemon socket exists — the common case
when an in-place update bumps PROTOCOL_VERSION with terminals running.
It forwarded write but not onWriteUnavailable, so the fan-out reached no
listener and only the written pane recovered: STA-2373 unfixed, silently.

Also stop rejecting writes on adapters that cannot respawn. Legacy
adapters have no respawn, so the remount reattaches to nothing and
rebuilds the pane empty, losing scrollback the user could still read —
worse than the pre-existing silent drop. And guard the renderer's
write-unavailable handler on ptyId like its sibling data/replay handlers,
so a transport that rebinds without detaching cannot remount a healthy
pane.

* fix(daemon): route the write-unavailable fan-out through the degraded provider

DegradedDaemonPtyProvider is the live localProvider in degraded launch
mode and main subscribes on it, but it forwarded onData/onExit/onReplay/
onBackgroundStreamEvent and not onWriteUnavailable — so the fan-out
reached no listener and siblings stayed frozen. Same defect as the router,
one provider over.

The file sat at its max-lines ceiling, so make room by reusing one
combineUnsubscribes helper across the three places that already repeated
that loop rather than bumping the limit. Forward to the daemon adapters
only: the local fallback has no dead-socket problem.

* refactor(daemon): share the listener-fanout unsubscribe combination

Adding onWriteUnavailable to both provider wrappers left each file at
exactly 300/300 lines, so the next line anyone added would have broken
max-lines with no sanctioned escape hatch. Both already repeated the same
combine-unsubscribes loop, so lift it into one module: duplication drops
and each file gets its headroom back.

* fix(test): stop the fake emitter colliding with the private adapter emitter

DaemonPtyAdapter.emitWriteUnavailable is private, so declaring a public
member of the same name on a mock intersected with DaemonPtyAdapter
collapsed the whole type to never — one collision produced 54 typecheck
errors, taking out pre-existing assertions in both files too. Rename the
fake to triggerWriteUnavailable and declare onWriteUnavailable on
ProviderMock, which IPtyProvider does not carry on this branch.

vitest does not typecheck, which is why a red build sat behind a green
suite.

* fix(quick-commands): show pointer cursor on hover (#10223)

* fix(terminal): limit pre-paint WebGL resume to macOS (#10794)

Run terminal visibility transitions pre-paint only on macOS. Restore passive disposal and recreation on Windows/Linux, remove the Windows retained-context LRU machinery, and preserve the normal 128-context startup ceiling.

* fix(ssh): connect to Linux hosts that cannot compile node-pty (#10776)

* fix(ssh): connect to Linux hosts that cannot compile node-pty

node-pty ships no Linux prebuilt at any architecture, so it is compiled on
the remote. On a host without a C/C++ toolchain that build fails, and because
both native deps install in one npm command it also took down
@parcel/watcher — which does have a working Linux prebuilt — and failed the
whole connection. Every Linux image without build tools was unusable.

node-pty only backs remote terminals; files, git, and the editor do not need
it, and a missing native dep is already non-fatal further down the deploy. So
when the existing toolchain probe confirms the compiler is missing, reinstall
without node-pty instead of aborting. The manifest has to drop it too — npm
reconciles every dependency in package.json, not just the ones named on the
command line, so naming only @parcel/watcher still rebuilds node-pty.

If that reinstall also fails the actionable build-tools error is rethrown, so
a host broken for some other reason still reports the toolchain gap.

The relay's PTY error now names the fix rather than saying only that node-pty
is unavailable.

Verified on a stock Rocky Linux 10.2 aarch64 container (openssh-server, git,
nodejs, npm, no compiler): connect succeeds, /etc lists over SSH, node-pty is
absent while @parcel/watcher installs its linux-arm64-glibc prebuilt, and
spawning a terminal reports the install hint.

* fix(ssh): keep the node-pty skip path honest about platform and watcher

The PTY unavailable message named build tools unconditionally, but only Linux
compiles node-pty — the deploy-side skip is gated on linux and the toolchain
probe returns null on Windows. A Windows or macOS remote, where node-pty ships
prebuilds, was told to install make/g++/python3. Pick the remedy by the relay's
own platform.

The skip path returned before the install probe, so a @parcel/watcher that
installs but cannot require() (glibc below the floor) connected with dead file
watching and nothing logged. Probe before returning and warn; no rebuild, since
node-pty provably cannot compile on that host, and never fatal.

Also log the pty-less reinstall's own failure and attach it as cause — the
rethrown toolchain message is built from the original npm error, so an
unrelated retry failure (registry, ENOSPC, EACCES) was lost. The reinstall now
keeps the caller's resetDeps as well, so a repair reconnect still clears every
dep the probe found broken.

Tests: the skip-success fixture queued a chmod/probe/rebuild sequence
production never runs, and the surplus slots were absorbed by launchRelay's
readiness poll (1817ms vs 3-9ms for its peers). It now emits exactly the 12
execs production performs, and pins that no rebuild is issued. Adds the missing
negative case: a gyp-shaped failure on a host whose probe reports a complete
toolchain must still hard-fail rather than silently degrade.

* fix(ssh): hedge the node-pty remedy and keep repair resets on the skip path

* perf(terminal): bound the PR-link carry scan to the trailing window (#10741)

* fix(codex): treat non-Codex transcript as stale resume metadata (#10538)

When a pane is mislabeled agent:codex but still holds a Claude
transcriptPath, the Codex resume guard threw and blocked relaunch.

Only hard-fail when the path claims Codex's dated rollout layout
(sessions/YYYY/MM/DD/rollout-*.jsonl), under any home and without
requiring the file to exist. Paths that never claimed Codex provenance
return null so the pane can relaunch.

Keying on rollout shape rather than trusted-home membership matters:
returning null only declines to override CODEX_HOME, and the renderer
has already baked 'codex resume <id>' into the command. A real rollout
under an untrusted home would otherwise resume under whichever account
is selected — and once the session bridge hardlinks rollouts across
homes, codex would find that id and resume silently under the wrong
account. Trust is decided upstream by findTrustedCodexSessionResume.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>

Closes #10517

* fix(codex): cache weekly-only accounts when switching Codex accounts (#10467)

* fix(codex): cache weekly-only accounts when switching Codex accounts

refreshForCodexAccountChange snapshotted the outgoing account only when
this.state.codex.session was populated. Weekly-only plans report no session
window, so their snapshot was dropped and the account switcher's inline bars
rendered empty for exactly those accounts.

Accept a populated weekly window as well. #10136 made this reachable: before
duration-based classification, a weekly-only quota landed in the session slot,
so the gate happened to pass.

Claude is intentionally untouched; it has no weekly-only plan shape.

* test(rate-limits): pin that a windowless outgoing Codex account is not cached

The widened weekly-only gate had no test for its lower bound: replacing it with a bare truthy check on state.codex passed all 70 tests, which would cache an empty fetching placeholder and render a blank inline bar row in the switcher.

* release: v1.4.159-rc.0

* fix(terminal): kill floating, setup, and folder-workspace PTYs on tab close (#10810)

* perf(terminal): coalesce per-keystroke input stamps + add typing-latency self-diagnostic (#10784)

* perf(terminal): coalesce per-keystroke input stamps + add typing-latency diagnostic

A user reports keystroke-echo lag on v1.4.156 that vanishes in Ghostty on the
same machine, at a scale we cannot reproduce locally (nested worktrees, ~20
agents each). Static analysis across the 155..156 renderer diff found no
perceptible regression, so this adds the instrument to measure it where it
actually happens.

Diagnostic (`window.__orcaTypingDiagnostic`, dev-console only, no shipped UI):
reports keydown->paint percentiles from real typing plus a scale census —
agent rows store-total vs mounted-DOM, store listener count, worktree nesting
depth, the settings gating suspect paths, and the focused pane's agent and
buffer mode. Nothing attaches to the keystroke path until start(), so it does
not perturb the latency it measures.

Coalescing: recordTerminalInput wrote the whole lastTerminalInputAtByPaneKey
map on every keystroke, waking every zustand subscriber. Hibernation is a >=60s
idle timeout, so the leading edge of a burst writes immediately and the rest
collapse into one trailing flush. Imperative readers merge the pending stamp,
and a late flush never revives a pane key teardown deleted.

This write path is byte-identical in v1.4.155 and v1.4.156, so the coalescing
is a general perf win, not a fix for the reported regression — the measured
saving (~0.02ms/keystroke) is well below perception.

* fix(diagnostic): count React store subscriptions in the listener census

The census wrapped `subscribe` on the bound hook after `create()` had already run.
zustand's `useStore()` reads the INNER `api.subscribe`, and `create()` copies
subscribe onto the hook as a separate property slot — so patching the hook's copy
counted only the 16 imperative `useAppStore.subscribe()` call sites and missed all
~2.2k React hook subscriptions, i.e. exactly the ones that scale with agent rows.
The metric would have read a near-constant ~16 regardless of scale, which would
have made "latency tracks listener count" read as false no matter the truth.

The inner api is only reachable as the state creator's third argument, so the
counter now installs there and lives in the store rather than the probe.

Still per-subscribe (component mount), never per-setState: zustand notifies by
iterating its listener Set directly, so this never touches the keystroke path.

Tests pin both subscribe paths; reverting to the old wiring fails 3 of the 5.

* fix(ssh): stop remote terminals fail-opening to local PTY (#10778)

* fix(ssh): stop remote terminals fail-opening to local PTY

Docker SSH watcher isolation failed because an unhydrated remote worktree
spawned through the local daemon with a container-only cwd. Fail closed
while the SSH owner is still loading, ignore non-PTY mux notifications
before mapping params.id, and harden the Docker SSH e2e connect helper so
the repo connectionId is present before terminal activation.

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

* fix(terminal): keep host-agnostic terminals off the hydration guard

The unresolved-owner guard also caught floating and inline setup terminals,
which have no repo row by design, regressing #10151. Scope it to repo-backed
worktrees and cover the SSH hydration window with a regression test.

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

* fix(terminal): treat a local-stamped worktree as a resolved host

The hydration guard keyed off "no repo row", which also withheld spawn for a
local worktree whose own hostId already proves its host. Key it off "nothing
names the host" instead, and cover the local-stamped case with a test.

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

* fix(terminal): recover parked panes when their host hydrates

Withholding the spawn stopped the wrong-host PTY but left the pane inert:
nothing bumped its generation once the repo row merged, so a remote terminal
still never opened. Remount PTY-less tabs when repos:changed resolves their
owner, and drop the e2e helper workaround that was masking this.

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

* fix(terminal): scope hydration remounts to panes that actually parked

Keying recovery off "tab has no PTY" also matched tabs whose shell merely
exited, remounting them on every repos:changed. Track the panes that withheld
their spawn and consume each entry once, so recovery cannot churn a live
terminal or spin on repeated refreshes.

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

* test(terminal): cover the repos:changed parked-pane remount wiring

The recovery predicate was unit-tested, but nothing proved useIpcEvents
actually calls it — the existing suite stubs repos.onChanged as a no-op.
Drive the real listener and assert a parked pane is remounted only after
its host resolves.

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

---------

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

* fix(memory): clarify Resource Manager accounting (#10821)

* perf(agent-status): scan Command Code transcripts backward from EOF (#10742)

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

* fix(terminal): quarantine input after a replaced PTY endpoint (STA-2373) (#10811)

Follow-up to #10065, which merged with this failure mode known and deferred.

Recovery from a dead daemon socket reattaches to a fresh shell. Keystrokes in
flight during the ~1.1s window are dropped, but everything typed after reattach
lands on the new shell, so the surviving tail of a half-sent line is submitted by
the user's own Enter: `echo hi; rm -rf x` arrives as `cho hi; rm -rf x` — zsh
fails `cho` and still runs `rm -rf x`. Before #10065 the whole line was lost, so
the executing tail is new.

Quarantine the remainder of the interrupted line instead. Keyed by tab, not pane:
recovery destroys the xterm being typed into and the successor pane receives the
tail. Armed only from the onWriteUnavailable path; a stalled-pipeline remount
keeps the same live shell, where quarantining would eat a real command.

Disarms on the line terminator (CR/LF/Ctrl-C, dropped too since that is the byte
that would submit the mangled line), a 700ms idle gap, or a 5s cap. The cap must
not be shortened: the tail itself takes ~2.5s to type, so a shorter cap fires
mid-tail and delivers the dangerous remainder to the fresh shell.

The onData check sits after the query-reply branch so CPR/DSR replies still reach
the shell.

Live A/B QA on macOS: the bug reproduces verbatim without the fix (marker file
created by the surviving tail) and is suppressed with it, with the next command
still working — proving suppression rather than a dead pane.

* perf(agent-status): keep the shared transcript reader's carry linear (#10777)

readLastTextFromTranscriptOnce re-joined its carry buffer on every block that
held no newline, so a transcript whose tail is one oversized line copied
O(line^2). It backs three readers — the Claude/Codex user prompt, the Command
Code assistant message, and the shared assistant-text reader — so every agent
that resolves turn text from a transcript paid it.

Same chunk-list carry the Command Code prompt reader already uses. Measured on
a transcript whose tail is one big line: 15.24 ms -> 8.87 ms at 3.9 MB, and the
gap widens with the line, which is the quadratic signature.

* perf(ai-vault): keep the JSONL line carry linear (#10783)

consumeCompleteJsonlLines re-joined its held-over partial line with every
stream chunk, so one oversized record — a large tool result — cost O(record^2).
It backs the incremental parse for every resumable agent transcript, so the
whole AI Vault corpus paid it.

Hold the pieces in a list and join once, when a newline finally arrives.
Measured on a transcript with a single oversized record: 2.13 ms -> 1.17 ms at
1 MB and 68.22 ms -> 4.35 ms at 8 MB, with byte-identical output. A transcript
of ordinary records never reaches the branch.

* perf(git): read both diff blobs concurrently (#10781)

* perf(git): read both diff blobs concurrently

The diff loaders awaited their two sides in series, so the second `git show`
could not start until the first had returned. The reads are independent, so
that was pure added latency on every diff the review panel opens: ~47 ms
sequential vs ~24 ms concurrent, a saving of ~23 ms per diff.

Covers the merge-base, commit, and staged loaders, plus the unstaged path where
the working-tree read is independent of the index->HEAD chain. The unstaged
left chain itself stays sequential because its second step depends on the first.

The staged coalescing test asserted the sequential shape (one spawn, then the
next); it now pins the contract that actually matters — eight identical reads
still collapse to two spawns, one per side.

* test(perf): interleave the diff-blob benchmark arms

Running one strategy's whole batch before the other's lets cache warming, CPU
frequency drift, and background load correlate with the strategy being measured.
Alternate the arms per iteration, alternate which goes first, and report medians
so that drift stays common to both.

Also reject malformed env settings rather than truncating them — Number.parseInt
accepts "10foo" and 3.5.

Interleaved result confirms the original: 1.90x-2.03x, ~24 ms saved per diff.

* perf(mobile-sync): memoize the agent-status projection per entry (#10787)

* perf(mobile-sync): memoize the agent-status projection per entry

buildRuntimeMobileAgentStatusProjection re-serialized every live agent on every
status ping. setAgentStatus replaces one entry and re-spreads the map, which
defeats the reference-equality skip gate, so each ping paid for every other
agent's prompt, 20-entry history, and 8 KB assistant message to discover they
had not changed.

Memoize each row's JSON by entry identity, the cachedTabsProjection pattern
already used a few functions above. Per ping: 0.18 ms -> 0.014 ms at 8 agents,
0.88 ms -> 0.063 ms at 40. The output is byte-identical — joining pre-serialized
rows matches whole-array stringify, which the new test pins against a verbatim
copy of the old implementation.

* test(perf): stop inflating the projection benchmark baseline

The pre-fix arm stringified each row and parsed it back before stringifying the
array, a per-row roundtrip the original never paid. That made the baseline
artificially slow: the reported 5.9x-14.0x is really 2.1x-5.0x.

Share one row builder between both arms, and check equivalence after a ping as
well as on the cold call — a stale-row bug can only surface once the cache is
actually exercised, which the cold-path check could never catch.

* perf(startup): stop parsing qrcode and @linear/sdk at launch (#10788)

Both are reachable only from features most users never touch, but both were in
the main bundle's eager top-level require block.

@linear/sdk is the sharper case: linear-sdk.ts exists solely to load that ~2.6 MB
CJS bundle lazily, and a single value import in issue-relation-write.ts defeated
it for everyone. That file now imports the type and goes through the loader.

qrcode is only reachable from mobile pairing, and both call sites were already
async, so they take a dynamic import.

* fix(codex): rank the Codex resume rescan by selected account, not settings order (#10801)

The legacy Codex session-id rescan (used when a persisted record has no
transcriptPath) returned the FIRST trusted home holding a rollout with that id.
That home becomes the pane's CODEX_HOME — i.e. it picks the ACCOUNT — and the
list ended in per-account homes ordered by settings INSERTION order, so the
account was decided by whichever one the user happened to add first.

Ranks instead: selected account -> real system home -> shared runtime mirror ->
everything else by normalized path. Both ranking inputs are required (an
optional one would silently degrade to pure path order), and the selection
arrives as a thunk so the common provenance-present resume never stats the
ownership marker for a ranking it never runs.

The mirror needs its own tier: prepareLegacySharedCodexSessionResume's guard
only fires when the resolved home IS the mirror, and that guard is what
migrates the rollout into ~/.codex. Without it a system-default selection
silently resumed under an arbitrary account and stayed pinned there permanently
once the hook stamped a transcript path.

Reviewed over two independent rounds; every tier individually mutation-proved.
Live-validated in a real Orca dev build by reading the spawned PTY's actual
CODEX_HOME across three builds (head, base, and head-minus-the-mirror-tier).

Note: fixes none of #10757's user-visible symptoms on its own — it is a
correctness precondition. Verified on macOS only; Windows coverage is
fixture-only.

* fix(sidebar): make worktree drag reorder follow the card, not the pointer (#10845)

* fix(mobile): harden native chat send budgets, streams, and stop (#10814)

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

* perf(source-control): share one path collator across the projection (#10850)

* lint: guard the two perf bug shapes we fixed repeatedly (#10851)

* fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841)

* fix(terminal): avoid flash while restoring parked terminals (#10871)

* Fix diff notes overlapping following lines (#7803)

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

* fix(terminal): clear search highlights when the find bar closes (#10872)

Closing Ctrl+F left one match highlighted until the window was minimized
and restored.

xterm's DecorationService keys its SortedList on `decoration.marker.line`,
but `SortedList.delete()` only records an index and defers compaction,
while `Marker.dispose()` sets `line = -1` — mutating that same sort key.
After the first disposal the array is no longer sorted, so the binary
search inside `delete()` can miss a decoration that is present. It returns
false, `onDecorationRemoved` never fires, and the decoration stays live and
keeps painting. Repaints don't help; they faithfully re-paint a live
decoration, which is why only a window cycle appeared to fix it.

`clearDecorations()` disposes the active match before the match
highlights, which is exactly the order that trips this.

Patch `delete()` to retry once after compacting pending deletions, on the
miss path only, so the common bulk delete keeps its O(log n) search and
deferred batching. A 3000-trial randomized differential against upstream
semantics shows no behavior change for well-ordered lists.

* perf(cli): load only the handler group a command dispatches into (#10883)

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

* perf(ssh): normalize watch event paths once per fs.changed batch (#10881)

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

* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental) (#8549)

* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental)

Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed marketplace v0 with consent, provenance and
kill-list enforcement.

Theme, icon-theme and terminal-theme contributions are deferred to a
follow-up pass.

* fix(plugins): make unsupported marketplace listings unreachable by key

findPlugin() backs preview/install/previewInstalledUpdate via
requireListing(), so filtering only listPlugins() hid the catalog card
while leaving the dead install path reachable one click later.

* fix(plugins): fan Pi session-only status out to plugin subscribers

The providerSessionOnly early-return in applyNormalizedStatus emitted to
onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so
plugins subscribed to agent.status.changed silently missed every Pi
session_start event. Route both emit sites through one helper so a future
early return cannot drop the plugin tap again.

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

* plugins: drop dead code and hoist duplicated trust-boundary patterns

Cleanup pass over the P1 diff, no behavior change:

- Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus
  the now-vestigial `directories`/`signal` plumbing in `collectFiles`.
- Delete `resolveContainedPluginDirectory` (no callers).
- Delete `plugin-content-load-pool.ts`; it reimplemented the existing
  `mapWithConcurrency`, whose index arg also removes the pairing wrapper
  in `buildPluginList`.
- Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into
  the install-lockfile module; 11 sites hand-rolled these identically.
- Point the new reliability gate at the PR instead of gitignored docs
  paths, matching every other gate's link form.

* fix(plugins): retry plugin state renames on Windows AV/EPERM locks

Six plugin write paths (lockfile, provenance, current pointer, kill
list, marketplace cache, staged install dir) did a plain rename, so an
antivirus or indexer holding the target open surfaced as a failed
install. The repo already retries this hazard for issue #1507, but only
through a sync helper; these paths are all async.

Adds one bounded async retry + atomic write used by all six, and trims a
consent-provenance header that restated its own JSX.

* test(plugins): cover the Windows rename retry path

The retry loop shipped untested: both existing cases hit the non-retry path,
and the temp-cleanup test passed identically with the `finally` removed.
Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke.

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

* fix(plugins): pin bundled plugin resources to LF

Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived
as CRLF and verify-packaged-plugin-resources rejected it — the packaged build
could never pass on Windows. Reproduced locally: CRLF yields the exact CI
error, LF verifies clean. Files are already LF, so nothing renormalizes.

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

* test: guard the bundled-plugin LF pin against a CRLF checkout

The byte-hash mismatch only surfaced in Windows packaging CI. Assert the
.gitattributes pin and that a CRLF tree is rejected, so a regression fails
on any platform instead of waiting for a packaged Windows build.

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

* ci: trigger packaged-build check on bundled plugin resource changes

The launch tree is byte-hashed during packaging, but no trigger path covered
it — so the CRLF fix for that check would not have re-run the check. Add the
resources, verifier and .gitattributes paths that can break packaging.

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

* perf(plugins): rebuild the panel frame only when its baked theme values change

The revision keys the panel iframe, so every bump destroys the sandboxed
frame and its in-panel state. It counted root attribute mutations, but
--workspace-sidebar-live-width is written every rAF of a sidebar drag, so
dragging with a panel open blanked it ~60x/sec. Compare the two values the
shell actually bakes in instead.

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

* test: stop pinning a plugin name in the CRLF guard

The CRLF case rewrites every launch file, so the reported mismatch is
whichever plugin sorts first. P2 adds theme plugins that sort ahead of
orca-navigation-shortcuts, which broke the assertion there.

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

* style: drop stray blank lines left by the rebase resolutions

Both sides of the agent-hooks and orca-runtime conflicts contributed a
trailing blank, which oxfmt rejects. Whitespace only.

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

* test(plugins): stop the startup budget failing on machine load

P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite
parallelism, so the gate flaked. Widen it to catch an order-of-magnitude
regression instead; the no-worker/no-plugin-code assertions are the real
guarantee. Verified a 400ms regression still fails.

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

---------

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

* fix(tab-bar): keep tab menu items on one line and give every item an icon (#10882)

* fix(remote): accelerate terminal recovery on resume/online (#8255)

* fix(remote): accelerate shared-control and pane recovery on resume/online

Narrow #8255 onto current main after #9774: fire pending shared-control
reconnect timers and pane recovery backoffs on system resume and browser
online, without replacing the per-pane recovery state machine or reconnect
banner UX.

* test(remote): cover online and occluded-resume recovery triggers

* fix(remote): centralize recovery acceleration

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(mobile): unblock iOS releases and prepare 0.0.36 (#10888)

* fix(mobile): block iOS uploads below the last shipped App Store version

The closed-train guard looked up each candidate version's own App Store
record, but a version only gets one once it is submitted for review.
0.0.34 reached TestFlight and was never submitted, so it had no record,
nothing looked closed, and the patch-bump walk stopped there — while
0.0.35 had already shipped. Apple rejected the upload after a 24-minute
build (90186 closed train, 90062 needs a higher CFBundleShortVersionString).

Fetch the highest closed version once and treat everything at or below it
as closed, comparing semver numerically so 0.0.10 outranks 0.0.9.

Also read appVersionState alongside appStoreState: the latter is
deprecated in App Store Connect API 3.3 and renames the shipped state to
READY_FOR_DISTRIBUTION, so reading only the old field would silently find
zero closed versions once Apple stops populating it.

* chore(mobile): prepare 0.0.36

app.json sat at 0.0.32 while 0.0.35 shipped on the App Store, because
release versions are resolved on the runner and never committed back.
Close the four-version drift so the checked-in version matches reality
and the iOS release no longer depends on the closed-train walk to find
an open version.

Bump Android versionCode 8 -> 9 in the same commit: the version is shared
between platforms, and shipping 0.0.36 with the code that already shipped
for 0.0.32 produces an APK that cannot install over the released build.

* fix(relay): back off overloaded assignments (#10894)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* release: v1.4.160-rc.0

* perf(agent-status): strip terminal control bytes by run, not per character (#10866)

* perf(agent-status): strip terminal control bytes by run, not per character

stripTerminalControl built its result with a per-character `+=`, allocating a
fresh string for every retained character. The Command Code status detector
calls it four times per PTY chunk — the scan text, the chunk-boundary variant,
and both previous-text lengths — so an agent pane paid that on every write.

Control bytes are sparse in real output, so copy the spans between them instead:
2.3x-2.6x from 5 KiB to 106 KiB chunks. Output is byte-identical, checked
exhaustively over every string up to length 4 across a 13-symbol control/unicode
alphabet plus 200k random strings (224,831 inputs, 0 mismatches).

* docs(agent-status): condense the run-copy rationale comments

Review feedback: both comments walked through the implementation. Keep one line
of non-obvious rationale each, per the repo's comment guidelines.

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

* test(agent-status): correct terminal strip benchmark

* test(agent-status): bound terminal strip benchmark

---------

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

* perf(usage): yield with setImmediate, not a clamped setTimeout(0) (#10892)

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

* fix(terminal): restore link hover after mouseleave (#10903)

* fix(terminal): restore link hover after mouseleave

* test(terminal): verify mouseleave listener cleanup

* test(terminal): assert link hover listener wiring

* Update README downloads badge

* fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459) (#10893)

* fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459)

Resource Manager decided a session was an "orphan" from the absence of a
renderer binding, then force-killed it with no prompt. Absence of a binding is
not evidence a session is idle — during restore the binding map is legitimately
empty, and deferred SSH sessions never appear in it at all. Live agent sessions
were destroyed this way, losing unrecoverable work.

Three gaps, one rule: only positive evidence authorizes destruction.

- `pty:listSessions` dropped `agentSessionOwners` at the IPC boundary, so the
  renderer could not see the one fact that proves work is running. It now
  reports `hasAgentOwner`, typed once in `shared/pty-listed-session.ts` so the
  main handler, both preload surfaces, and the renderer cannot drift.
- The binding index ignored `deferredSshSessionIdsByTabId` — sessions restore
  knows are live on an SSH host but has not reattached. No other binding source
  can see them.
- The bulk-kill handler filtered sessions separately from the button's count,
  so the set killed could differ from the set advertised. Both now call
  `selectUnboundDaemonSessions`.

The single-row kill path had the same defect: it skipped confirmation whenever
`bound` was false. `requiresKillConfirmation` now also holds for agent-owned
sessions, and snapshot-derived rows carry ownership across from the daemon list
rather than reporting `false`.

* fix(resource-manager): distinguish unprovable ownership from proven absence

Adversarial review of the previous commit found it committed the same class of
error it was fixing: it collapsed "no agent owns this" and "this provider cannot
tell me" into one boolean `false`, and both destructive paths read that as proof.

A daemon generation below the claim protocol, an older SSH relay, or the
in-process local fallback all list no owners for a session that may well have
one. `pty.ts` already encodes the rule at :613 — "only providers that serialize
claims may make listing absence authoritative" — and the new IPC row ignored it.
So after upgrading with a legacy daemon still holding a live agent terminal,
bulk cleanup would have destroyed it: exactly #8459, one layer down.

`hasAgentOwner: boolean` is now `agentOwnership: 'present' | 'absent' | 'unknown'`,
derived via `providesAgentSessionOwnerListings`. Only `absent` authorizes
destruction, so `unknown` protects and confirms.

Second defect, found independently by four review lenses: the deferred-SSH
bindings reached the bulk selector but not `mergeSnapshotAndSessions`, because
the merge call site re-listed the binding fields instead of reusing the object.
A deferred SSH session therefore rendered `bound: false`, and its single-row kill
skipped confirmation while bulk cleanup correctly spared it. The call site now
spreads `resourceSessionBindings`, and a parity test fails if any binding field
is re-listed inline — the drift itself is now impossible to reintroduce quietly.

The e2e ownership assertion was also weak: it checked only that a boolean
arrived. It now asserts the exact arm, and that the live local provider reports
`absent` rather than `unknown`, so a degenerate all-unknown implementation fails.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* feat(usage): price Claude 5 family and GPT-5.6 token usage (#10822)

* feat(usage): price Claude 5 family and GPT-5.6 token usage

Claude Opus 5, Sonnet 5, Fable 5 and Codex gpt-5.6 sol/terra/luna were
absent from the usage pricing tables, so their turns aggregated tokens
but reported no estimated cost.

Rates from Anthropic and OpenAI published pricing. Sonnet 5 gets no
long-context tier: Claude 4.6 and later bill the full 1M window flat.

Sonnet 5 uses the standard $3/$15 rate, not the $2/$10 introductory rate
that runs through 2026-08-31 — the table has no date dimension.

* fix(usage): price the bare gpt-5.6 alias and assert Opus 4.5 separately

OpenAI routes the bare `gpt-5.6` alias to Sol, but only the explicit
`-sol` / `-terra` / `-luna` IDs resolved, so alias-recorded sessions still
reported no cost. Match it exactly rather than by prefix so it cannot
swallow the tier IDs or a future cheaper variant.

Also split the Claude 5 shadowing guard into per-model breakdown
assertions and add the missing Opus 4.5 fixture the test name claimed.

* docs(usage): note Sonnet 5 uses standard, not introductory, rates

* fix(editor): save floating workspace markdown files (#10985)

* fix(worktrees): resolve a two-host project by the worktree's own host (#10634) (#10986)

* fix(worktrees): resolve a two-host project by the worktree's own host (#10634)

A project registered on both a local host and an SSH host permanently poisoned
every one of its workspaces with "Workspace identity is ambiguous across hosts.
Refresh projects and try again." Refresh could never help: nothing was stale,
both host setups were valid and intentional. The error survived restarts.

The ambiguity was manufactured. `resolveExactWorktreeRoute` starts from a
worktree that already carries exactly one `hostId`, then throws that away and
asks `resolveIndexedRepoOperationRoute` which host owns the *repo* — a question
with two right answers once a project spans hosts. Only the project spans hosts;
each worktree never does.

Route resolution now filters repo setups to the ones matching the worktree's own
host before looking for a transport, so a two-host project resolves as cleanly as
a one-host project. Genuine ambiguity still returns `ambiguous`.

Second half: the error escaped as an *uncaught renderer error* because passive
background paths — unread marking, activity bumps — called a helper that threw.
Those callers now degrade: `trySettingsForWorktreeOwner` returns null, the
passive update is skipped with a warning, and local state stays consistent.
Explicit user actions still surface the error.

* fix(worktrees): cover every passive path and warn once for ambiguous owners

Adversarial review found the routing fix sound but its coverage thin: only
markWorktreeUnread had an ambiguous-owner test, so restoring the throw in
clearWorktreeUnread or bumpWorktreeActivity would have reproduced the uncaught
renderer error with the suite still green. Both are now covered, verified by
mutation.

bumpWorktreeActivity also skipped silently where the other paths warned. It now
warns — but once per workspace, not per event: activity bumps fire on every PTY
event, so an unbounded warn would flood the console for exactly the users already
hitting this bug.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* perf(runtime): remove timer clamps from cooperative yields (#10908)

* perf(runtime): remove timer clamps from cooperative yields

Renderer paste and input loops can schedule more than a thousand zero-delay timer yields for a maximum-size payload. Chromium clamps nested timers to 4ms, adding seconds of idle wall time.

Use MessageChannel tasks in renderer runtimes and setImmediate in Node while retaining a timer fallback for tests and unsupported environments.

* fix(runtime): preserve pacing and release yield callbacks

Adversarial review found that concurrent producers could retain resolved callbacks until global quiescence. Route renderer yields by token and delete each resolver before resuming its producer.

Keep timer pacing in terminal paste and accepted-write loops where SSH and local PTYs do not provide drain acknowledgement. Use the shared scheduler for the OpenCode scanner.

* Implement robust orchestration primitives and connected-server workers (#9925)

* fix(gitlab): render item descriptions and comments with document markdown variant (#9161)

Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: parallelize PR checks and accelerate Vite builds (#10989)

* ci: parallelize and accelerate PR checks

* fix(ci): make accelerated checks runtime-safe

* fix(ci): address review findings

* fix(ci): retry transient Electron downloads

* test(ci): cover Electron download retry limits

* fix(terminals): negotiate explicit close intent for paired runtimes (#10129)

* fix(cmd-j): focus the destination workspace's own terminal after a jump (#10695)

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

* fix(jira): render issue description/comment images with lightbox (#8938)

* fix(jira): render issue images and open them in a lightbox

Jira ADF media nodes were dropped when converting descriptions/comments
to Markdown, so screenshots never appeared in the Tasks drawer.

Download image attachments with authenticated Jira API access, embed them
as data URLs on issue/comment detail loads, and add a viewport-centered
lightbox. Closing with X/Esc only dismisses the preview, not the issue sheet.

* fix(jira): open comment images in the same lightbox as description

Jira issue comments still used compact markdown, so screenshots rendered
but could not expand. Use the document renderer for comment bodies, add a
regression test for the expand control, and sync MarkdownImageLightbox
locale keys.

* fix(jira): harden inline image handling

* fix(jira): harden inline image discovery, escaping, and downloads

Address PR review findings: correct media-attachment pairing, Server/DC
attachment lookup base path, markdown-safe external URLs, wider HTML
discovery with gated alt fallback, concurrent downloads outside the API
semaphore, and a main-process attachment data-URL cache with lower caps.

* fix(jira): Option A multi-same-name attachments and post-map media warns

Fix discovery so repeated alts (image.png) get distinct attachment ids,
flush resolution warns after ADF mapping using attachment-only stats,
clear attachment cache on clearToken with epoch-guarded singleflight,
and add Server comment path plus release-before-binary regression tests.

* fix(jira): simplify comment media request skip condition

Only needingCount determines whether to skip the attachment metadata
request — htmlIds alone cannot produce a download without needing
media. Add type annotation for mediaAttrs for clarity.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>

* fix(hooks): drain POSIX hook stdin without PATH (#10885)

Reviewed with an independent reproduction on macOS and Ubuntu 20.04. Fixed five test files that asserted the old literal strings, and switched the reader to `command -p cat` so it also survives hosts without /bin/cat.

* fix(terminal): disarm stale TUI modes when a pane confirms return to shell (#9608)

A TUI killed hard (SIGKILL, OOM, crash) never restores the modes it armed. When
its parent shell survives, the emulator keeps mouse tracking, focus reporting
and Kitty keyboard flags on: every pointer move over the pane lands as typed SGR
motion reports at the prompt, and the doomed process burns CPU parsing the
motion firehose while it lives.

Orca's existing mode cleanups all hang off dead-PTY paths (hibernation kill,
daemon reattach), so an agent dying under a live shell crossed none of them.

Fire POST_REPLAY_REATTACH_RESET at the pane-foreground-agent tracker's confirmed
return-to-shell transition, next to the sibling stale-title cleanup. That
transition is gated on a real foreground-process read rather than the bare
OSC 133;D, because a full-screen agent's nested command shells leak their own D
onto the main PTY. The write goes through the replay guard so xterm's auto
replies cannot leak to the shell as input.

The reused constant already excludes ?2004l, so the bracketed-paste protection
the live shell re-arms at its prompt is preserved.

* fix(release): restore the Windows inner-binary signature gate (#6487) (#10719)

* fix(release): restore the Windows inner-binary signature gate

electron-builder 26.9+ dropped the bundled 7zip-bin package, so the gate's
hardcoded node_modules/7zip-bin path stopped resolving in 1d2cd33c83. The
gate is fail-open, so it swallowed the error and 11 releases shipped with
no signature verification and an evidence artifact that looked clean.

Resolve 7za through app-builder-lib's toolset instead, and always record a
verdict so a degraded gate can't pass for a healthy one.

Refs #6487

* test(release): make the signing-gate structural tests assert executed code, not text

The round-2 harness matched /\bthrow\b/ and /\bcatch\b/ against raw block text, so
the word satisfied the assertion wherever it appeared. Downgrading the resolver
throw to `Write-Host "...would normally throw..."` — the exact silent fail-open
this PR exists to kill — left all 11 tests green.

Every span is now classified once (code / string / comment) by the same walk that
pairs braces, and assertions run against the string-and-comment-blanked view.
Blanking preserves length, so indices still line up across views.

Also re-anchors the catch-ordering test: `blockAfter(step, '} catch {')` picked
the first catch in the step, which stopped being the gate's own once the
persistence helpers grew theirs — moving the policy throw inside the try was
passing again.

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

* test(release): pin the evidence filename the gate writes to the one the upload collects

The upload step is `if-no-files-found: ignore`, so renaming the evidence file on
one side and not the other ships a green run whose artifact silently omits the
verdict — the same silent-degradation class this PR exists to close.

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

* fix(release): preserve 7za resolver failures

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(shortcuts): gate Cmd/Ctrl+N folder-workspace jumps on path status (#10748)

Reviewed with an independent reproduction, including a live-app check. Replaced tests that mocked the module containing the fix, and reused the existing workspace activator instead of adding a 4th copy of the dispatch.

* fix(pty): strip inherited Claude child-session stamps at spawn (#9961)

An Orca GUI or daemon launched from inside a Claude Code session inherits
CLAUDE_CODE_CHILD_SESSION / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_BRIDGE_SESSION_ID.
Every spawn path spreads the host's process.env, so each terminal Orca opens is
marked a nested Claude child and Claude silently disables transcript
persistence — real sessions stop writing on-disk history with no visible error.

Older-protocol daemons are deliberately preserved across upgrades and the
auto-updater relaunch inherits the previous app's env, so one contaminated
launch propagates through subsequent updates.

Orca never sets these variables, so an inherited value is always poison. Add a
deny constant plus an inherited-only filter merged into envToDelete at both pty
spawn call sites, keeping a stamp explicitly passed in args.env. Unlike the
agent-hook keys this is not gated on isDaemonHostSpawn, because the local
provider and the relay host spread their own process.env too.

Review fixes: mergePtyEnvDeletions is now variadic (the nested form passed a
`string[] | undefined` intermediate into a `readonly string[]` parameter and did
not typecheck); coverage extends to the runtime-controller spawn path, the local
provider, and the SSH route, whose exact spawn-options assertion had to be
updated because envToDelete is no longer ever undefined.

* fix(sidebar): spin the worktree dot while Claude Code is thinking (#9040) (#10684)

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

* fix(cmd-j): restore focus when issue match routing declines (#11010)

* fix(markdown): render task continuations as text (#11008)

* fix(dashboard): stop the agent icon shrinking on long card titles (#11001)

A bare <svg> flex item shrinks with its row, so kanban cards and the
terminal dialog rendered a squashed ~9px agent icon whenever the worktree
name overflowed. Wrap both in the shrink-0 span every other surface
already uses.

* fix(runtime): refuse SSH hosts in project setup instead of acting locally (#10799)

* fix(runtime): refuse SSH hosts in project setup instead of acting locally

projectHostSetup.clone and .setupExistingFolder threaded executionHostId all
the way down but never used it for routing: cloneRepo runs a local mkdir plus
a local gitSpawn, and addRepo probes the path with existsSync/statSync. An
`ssh:` host therefore cloned and validated on the *local* machine and then
registered the result as living on the SSH host.

It only failed loudly here because the remote path did not exist locally. With
a plausible destination the clone succeeds and writes a setup record pointing
at the wrong machine.

Nothing legitimate sends `ssh:` to these RPCs: the renderer maps every ssh
host (including ephemeral-VM `ssh:runtime-ssh-*`) to the desktop IPC path,
which dispatches to addRemoteRepoFromPath/cloneRemoteRepo, and the IPC handler
symmetrically rejects `runtime:`. Only the CLI can reach here with `ssh:`.

Fail closed until the RPC learns to route through the SSH providers.

* test(runtime): make the SSH guard test observe the corruption it names

The test asserted `gitSpawn` was never called and no repo was registered, but
neither assertion could fail. `/home/brennan` is unwritable on macOS, so the
pre-guard clone died at `mkdir` before reaching `gitSpawn`, and
`/home/brennan/orca` failed `isGitRepo` before reaching `addRepo` — the exact
side effects under test were unreachable either way. `rejects.toThrow` also
aborted the test before those lines ran.

Use a real temp destination and a real temp git repo, await both calls via
`.catch`, and assert the side effects before the wording. With the guard
disabled the test now fails on `gitSpawn` being called once with a real
`git clone`, and on a repo registered stamped `executionHostId: 'ssh:openclaw'`
— the silent local-clone-recorded-as-remote defect itself. `gitSpawn` is
stubbed so a regression records the call instead of hitting the network.

Also document the SSH restriction on `project setup-existing-folder`, which the
guard now rejects. `setup-clone` already carried that note; its sibling did not.

* fix(browser): localize degraded cookie import warnings

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: grboy6770 <2000grboy@gmail.com>
Co-authored-by: ye4241 <6803102+ye4241@users.noreply.github.com>
Co-authored-by: Wooseong Kim <2222333+innocarpe@users.noreply.github.com>
Co-authored-by: SoonKim <sztolabs@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Ken Fukuyama <kenfdev@gmail.com>
Co-authored-by: SilvaViniF <108766450+SilvaViniF@users.noreply.github.com>
Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Anishek Kamal <anishekkamal@gmail.com>
Co-authored-by: BingZ <zzb@gxsmjx.com>
Co-authored-by: Tom <debres.bot@gmail.com>
Co-authored-by: Tom de Bres <tomdebres@users.noreply.github.com>
Co-authored-by: Andres Van Reepingen <andres.vanreepingen@student.ucll.be>
Co-authored-by: Andres Van Reepingen <andres.vanreepingen@datacamp.com>
Co-authored-by: xsacdw <251566507+xsacdw@users.noreply.github.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: reopard007 <reonce@gmail.com>
Co-authored-by: xsacdw <xsacdw@gmail.com>
Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Nemo <imshuazi@126.com>
Co-authored-by: nolainjin <nolainjin@gmail.com>
Co-authored-by: David Anderson <215816+akapug@users.noreply.github.com>
Co-authored-by: Vansh <77244334+vansh2408@users.noreply.github.com>
2026-07-27 16:40:29 -07:00
Neil 10ca89ac8b
feat(updater): switch to validated local mac builds (#10889)
* feat(updater): switch to validated local mac builds

* test(updater): cover local build recovery actions

* fix(types): keep local build contract in project sources
2026-07-27 16:36:39 -07:00
Neil 97cb32c1cc
fix(terminal): release an abandoned synchronized-output frame on reveal (STA-2694) (#10907)
* fix(terminal): release an abandoned synchronized-output frame on reveal

Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint
in `?2026h … ?2026l`. Hiding a pane mid-bracket — which a worktree switch or
cold-park lands on routinely, since these brackets are written many times a
second — leaves xterm's `decPrivateModes.synchronizedOutput` latched.

RenderService.refreshRows checks that latch *before* rendering, so while it
holds, every repaint Orca owns is a no-op: the forced render-pause repaint,
the plain `refresh()` fallback, and the shared glyph-atlas rebuild all render
zero rows while the xterm buffer is perfectly correct. Release the latch at
the two reveal repaint entry points so those repaints actually paint.

Also adds an OpenCode-shaped alt-screen e2e fixture and spec. The existing
inline-TUI convergence spec covers the normal-buffer shape (live block glued
to the bottom, history scrolling into scrollback); this covers the
full-screen alternate-buffer shape, where nothing scrolls and so no row ever
self-heals through the scroll path.

Scope note: xterm arms a 1s watchdog that clears this latch on its own, so
this closes a bounded window rather than the whole STA-2694 report. The e2e
spec passes with and without the production change for that reason; the unit
tests are what pin the behavior. Refs STA-2694.

* fix(terminal): clear the render model on the plain-refocus repaint path

`schedulePaneRevealPresent` — the atlas-preserving path a plain window
refocus takes — only called `terminal.refresh()`. xterm's renderers are
diff-based: `_updateModel` early-continues on any cell whose code/fg/bg/ext
still match the cached model, so a refresh repaints nothing for a pane whose
buffer never changed. When an occluded window loses its canvas contents while
that model stays populated, the refresh skips exactly the cells that went
stale and the pane keeps compositing pre-hide pixels — until a window resize
reallocates the model, which is the repair users find by hand.

Clear the model first (`RenderService.clear()` → renderer `clear()` →
`_clearModel(true)`) so the refresh becomes a guaranteed full repaint. That
drops cached cells and glyph vertices but NOT the texture atlas, which is
shared by every same-config terminal and whose mid-stream wipe re-arms xterm's
page-merge garble race (xterm.js #4480) — the reason this path is
atlas-preserving in the first place.

Also covers the DOM-renderer fallback in `resetWebglTextureAtlas`:
`clearTextureAtlas()` is what invalidated the model on the WebGL path, so a
pane without an addon had nothing invalidate it and hit the same skip.

Scope note: the e2e spec guards buffer/geometry convergence across the
hide/reveal boundaries and adds idle-agent and headful desktop-hide cases, but
it cannot observe a stale canvas — both oracles built for that (canvas-vs-buffer
ink sampling, screenshot-vs-forced-repaint) were proven blind by injecting the
defect, and the spec header documents why. The unit tests pin the ordering and
the atlas-preservation invariant. Refs STA-2694.

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

* docs(terminal): hand off the STA-2694 reveal-artifact investigation

Records both fixed defects with their xterm mechanisms, the reveal/wake call
graph, why every e2e oracle for a stale canvas was proven blind, how to arm the
in-app render-desync sentinel on real hardware, and the one unverified lead
(dimension staleness) that would explain why a window resize specifically is
the repair users find. Refs STA-2694.

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

* Revert "fix(terminal): clear the render model on the plain-refocus repaint path"

This reverts commit 0f7ec4458d37010338f16e70ff06957cb335e074.

* test(terminal): add a draw-command oracle for reveal repaints, and correct the STA-2694 scope

Every pixel oracle tried for STA-2694 was blind: `drawImage` on a
non-preserveDrawingBuffer WebGL canvas returns a re-rendered copy, and
Playwright's screenshot drives a fresh compositor frame that heals a stale paint
before capture. Reading pixels is self-defeating here — the read triggers the
repaint that hides the bug.

Count the WebGL draw commands instead, by wrapping GlyphRenderer.updateCell and
gl.drawElementsInstanced on the live pane. A draw command cannot be healed after
the fact, so "did the reveal actually repaint?" becomes directly observable.
Teeth-verified: removing releaseAbandonedSynchronizedOutput from
schedulePaneRevealPresent fails the stranded-latch test.

Two findings, both of which change previously-committed claims:

1. The 1s watchdog does NOT bound the synchronized-output defect. It is armed
   only inside `bufferRows`, and `refreshRows` returns at its `_isPaused` check
   first — so while a pane is occluded nothing reaches `bufferRows` and no timer
   is ever pending. A pane hidden mid-`?2026h` holds the latch with no watchdog
   behind it, indefinitely. ed1eaf55f1's "closes a bounded window" scope note was
   wrong; this is the unbounded garble the report describes, and the fix closes
   it. Corrected in the module doc comment.

2. It refutes the diff-based-staleness hypothesis behind 0f7ec4458d (reverted in
   8d5eacecb4). `_updateModel` does early-continue per unchanged cell, but
   `GlyphRenderer.render` then copies vertices for EVERY row up to
   `lineLengths[y]` and issues ONE full-viewport draw — measured identical
   instance counts (562) for a diff-skipped and a model-cleared refresh, with
   updateCell at 0 vs 561. The DOM renderer likewise replaceChildren()s every
   row unconditionally. Clearing the model could not change what reached the
   screen, and `_clearModel(true)` zeroes every glyph vertex while
   `RenderService.clear()` fires no repaint of its own — so it opened a
   blank-viewport window (also asserted here) for no benefit.

Also keeps the idle-agent and headful desktop-hide cases from the reverted
commit, since those were independent of the refuted production change, and
rewrites the alt-screen spec header to point paint questions at this oracle.
Refs STA-2694.

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

* docs(terminal): rewrite the STA-2694 handoff after the refutation

Records that the garble window is unbounded (the 1s watchdog never arms for an
occluded pane), that the diff-based-staleness hypothesis was refuted by
measurement and reverted, why pixel oracles are structurally blind here, and the
two leads now closed by measurement (dimension staleness, lazy atlas bindings).
Refs STA-2694.

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

* test(terminal): capture visual proof of the STA-2694 stale paint

The earlier screenshot oracles were blind because they compared a revealed pane
against a repaired one and both ran the same repaint code. Capturing the defect
directly works instead, because the mechanism is self-preserving: while
synchronizedOutput is latched, refreshRows returns before reaching the renderer,
so a compositor frame just re-composites the existing canvas texture and the
stale pixels survive the screenshot rather than being healed by it.

Latch a frame, write a full new frame the pane cannot paint, and capture. The
screenshot comes back byte-identical to the pre-hide one while the buffer holds
the new frame — the buffer/screen divergence users report — and differs after
the reveal repaint runs. Asserts both halves, so it fails if either the defect
stops reproducing or the fix stops repairing it.

Refs STA-2694.

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

* test(terminal): note where the xterm gate-order double is pinned for real

The unit double encodes RenderService's paused-then-latch gate order, which can
drift on an xterm upgrade. Point at the e2e oracle that pins the same order
against the real renderer, so a future upgrade has a trail to the authoritative
check. Refs STA-2694.

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

* test(terminal): add a perf budget for the synchronized-output release

releaseAbandonedSynchronizedOutput runs inside resetWebglTextureAtlas, which a
streaming alt-screen TUI can reach through the terminal-output atlas recovery
path — not only on reveal. Measure rather than assert that this costs nothing.

Steady state (a TUI that closes every frame it opens): 200 bracketed frames
produce zero releases, zero extra draw calls, and an unmeasurable early-out
cost. Worst case (every reveal finds a latched frame): 50 latched atlas resets
at 0.08ms each. Both are asserted with headroom, so the guard catches a future
change that makes this scan the buffer per pane rather than flaking on machine
speed. Refs STA-2694.

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

* test(terminal): address review — drive real code paths, close vacuity gaps

CodeRabbit caught a genuine tautology in the perf budget: it timed a
hand-copied mirror of the early-out rather than the shipped function, so the
assertion would have held even if the real code grew a buffer scan. Driving
resetWebglTextureAtlases instead moved the measured cost from ~0 to ~0.03ms per
call, which is the honest number for the whole recovery; bound re-set to 0.4ms
(10x measured).

Other review fixes:
- Assert the draw counts both perf tests were measuring and logging but never
  checking, so the 'no extra draws' titles now mean something.
- Fail fast when decPrivateModes is unavailable; previously the latched test
  would pass without ever exercising the fix.
- Re-check the latch right after the worktree switch in the mid-frame test: the
  pane is visible until then, so the 1s watchdog can arm and clear it before the
  hide, making the run vacuous.
- Count scheduleRevealPresent invocations instead of returning a literal true,
  so a missing test hook no longer masquerades as a production failure.
- Assert the latch clears on every reveal iteration, not just the last.
- Make the fixture heartbeat write atomic (tmp + rename); writeFileSync
  truncates first, so a reader could see '' and read it as frame 0.
- Relabel assertRevealPixelsNeedNoRepair as the weak secondary check it is; it
  contradicted the file header by calling itself 'the decisive paint assertion'.

Refs STA-2694.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 16:32:24 -07:00
giraphant cef52c68be
fix(runtime): stop rejecting ui.set when Linear or Jira resume fields are present (#10715)
Reviewed with an independent reproduction. Extended the fix to the UiUpdate parity gaps one level up, and added a typecheck-level assertion so future field drift fails the build instead of silently rejecting paired-client payloads.
2026-07-27 16:26:51 -07:00
Henry Su abcdc04f6b
fix(ci): mirror missing lint steps in PR workflow (#10601) (#10623)
Reviewed with an independent reproduction. Added the allowlist entry that unblocked verify:localization-coverage on main, the 4th drifted step, and a parity gate that fails when pnpm lint's chain contains a script absent from pr.yml.
2026-07-27 16:26:43 -07:00
Brennan Benson 3baffb49ff
fix(runtime): refuse SSH hosts in project setup instead of acting locally (#10799)
* fix(runtime): refuse SSH hosts in project setup instead of acting locally

projectHostSetup.clone and .setupExistingFolder threaded executionHostId all
the way down but never used it for routing: cloneRepo runs a local mkdir plus
a local gitSpawn, and addRepo probes the path with existsSync/statSync. An
`ssh:` host therefore cloned and validated on the *local* machine and then
registered the result as living on the SSH host.

It only failed loudly here because the remote path did not exist locally. With
a plausible destination the clone succeeds and writes a setup record pointing
at the wrong machine.

Nothing legitimate sends `ssh:` to these RPCs: the renderer maps every ssh
host (including ephemeral-VM `ssh:runtime-ssh-*`) to the desktop IPC path,
which dispatches to addRemoteRepoFromPath/cloneRemoteRepo, and the IPC handler
symmetrically rejects `runtime:`. Only the CLI can reach here with `ssh:`.

Fail closed until the RPC learns to route through the SSH providers.

* test(runtime): make the SSH guard test observe the corruption it names

The test asserted `gitSpawn` was never called and no repo was registered, but
neither assertion could fail. `/home/brennan` is unwritable on macOS, so the
pre-guard clone died at `mkdir` before reaching `gitSpawn`, and
`/home/brennan/orca` failed `isGitRepo` before reaching `addRepo` — the exact
side effects under test were unreachable either way. `rejects.toThrow` also
aborted the test before those lines ran.

Use a real temp destination and a real temp git repo, await both calls via
`.catch`, and assert the side effects before the wording. With the guard
disabled the test now fails on `gitSpawn` being called once with a real
`git clone`, and on a repo registered stamped `executionHostId: 'ssh:openclaw'`
— the silent local-clone-recorded-as-remote defect itself. `gitSpawn` is
stubbed so a regression records the call instead of hitting the network.

Also document the SSH restriction on `project setup-existing-folder`, which the
guard now rejects. `setup-clone` already carried that note; its sibling did not.
2026-07-27 16:21:54 -07:00
Brennan Benson 1fcbf8e5fe
fix(dashboard): stop the agent icon shrinking on long card titles (#11001)
A bare <svg> flex item shrinks with its row, so kanban cards and the
terminal dialog rendered a squashed ~9px agent icon whenever the worktree
name overflowed. Wrap both in the shrink-0 span every other surface
already uses.
2026-07-27 16:19:22 -07:00
OrcaWin 3716a7bb49
fix(markdown): render task continuations as text (#11008) 2026-07-27 16:17:58 -07:00
Neil 0ab5f499cb
fix(cmd-j): restore focus when issue match routing declines (#11010) 2026-07-27 16:06:17 -07:00
Neil a065db154c
fix(sidebar): spin the worktree dot while Claude Code is thinking (#9040) (#10684)
Co-authored-by: Orca <help@stably.ai>
2026-07-27 15:57:53 -07:00
David Anderson 2a640abfbe
fix(pty): strip inherited Claude child-session stamps at spawn (#9961)
An Orca GUI or daemon launched from inside a Claude Code session inherits
CLAUDE_CODE_CHILD_SESSION / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_BRIDGE_SESSION_ID.
Every spawn path spreads the host's process.env, so each terminal Orca opens is
marked a nested Claude child and Claude silently disables transcript
persistence — real sessions stop writing on-disk history with no visible error.

Older-protocol daemons are deliberately preserved across upgrades and the
auto-updater relaunch inherits the previous app's env, so one contaminated
launch propagates through subsequent updates.

Orca never sets these variables, so an inherited value is always poison. Add a
deny constant plus an inherited-only filter merged into envToDelete at both pty
spawn call sites, keeping a stamp explicitly passed in args.env. Unlike the
agent-hook keys this is not gated on isDaemonHostSpawn, because the local
provider and the relay host spread their own process.env too.

Review fixes: mergePtyEnvDeletions is now variadic (the nested form passed a
`string[] | undefined` intermediate into a `readonly string[]` parameter and did
not typecheck); coverage extends to the runtime-controller spawn path, the local
provider, and the SSH route, whose exact spawn-options assertion had to be
updated because envToDelete is no longer ever undefined.
2026-07-27 15:55:31 -07:00
Vansh ab60045371
fix(shortcuts): gate Cmd/Ctrl+N folder-workspace jumps on path status (#10748)
Reviewed with an independent reproduction, including a live-app check. Replaced tests that mocked the module containing the fix, and reused the existing workspace activator instead of adding a 4th copy of the dispatch.
2026-07-27 15:50:08 -07:00
Neil 39a200d900
fix(release): restore the Windows inner-binary signature gate (#6487) (#10719)
* fix(release): restore the Windows inner-binary signature gate

electron-builder 26.9+ dropped the bundled 7zip-bin package, so the gate's
hardcoded node_modules/7zip-bin path stopped resolving in 1d2cd33c83. The
gate is fail-open, so it swallowed the error and 11 releases shipped with
no signature verification and an evidence artifact that looked clean.

Resolve 7za through app-builder-lib's toolset instead, and always record a
verdict so a degraded gate can't pass for a healthy one.

Refs #6487

* test(release): make the signing-gate structural tests assert executed code, not text

The round-2 harness matched /\bthrow\b/ and /\bcatch\b/ against raw block text, so
the word satisfied the assertion wherever it appeared. Downgrading the resolver
throw to `Write-Host "...would normally throw..."` — the exact silent fail-open
this PR exists to kill — left all 11 tests green.

Every span is now classified once (code / string / comment) by the same walk that
pairs braces, and assertions run against the string-and-comment-blanked view.
Blanking preserves length, so indices still line up across views.

Also re-anchors the catch-ordering test: `blockAfter(step, '} catch {')` picked
the first catch in the step, which stopped being the gate's own once the
persistence helpers grew theirs — moving the policy throw inside the try was
passing again.

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

* test(release): pin the evidence filename the gate writes to the one the upload collects

The upload step is `if-no-files-found: ignore`, so renaming the evidence file on
one side and not the other ships a green run whose artifact silently omits the
verdict — the same silent-degradation class this PR exists to close.

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

* fix(release): preserve 7za resolver failures

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-27 15:48:39 -07:00
David Anderson c0734f039d
fix(terminal): disarm stale TUI modes when a pane confirms return to shell (#9608)
A TUI killed hard (SIGKILL, OOM, crash) never restores the modes it armed. When
its parent shell survives, the emulator keeps mouse tracking, focus reporting
and Kitty keyboard flags on: every pointer move over the pane lands as typed SGR
motion reports at the prompt, and the doomed process burns CPU parsing the
motion firehose while it lives.

Orca's existing mode cleanups all hang off dead-PTY paths (hibernation kill,
daemon reattach), so an agent dying under a live shell crossed none of them.

Fire POST_REPLAY_REATTACH_RESET at the pane-foreground-agent tracker's confirmed
return-to-shell transition, next to the sibling stale-title cleanup. That
transition is gated on a real foreground-process read rather than the bare
OSC 133;D, because a full-screen agent's nested command shells leak their own D
onto the main PTY. The write goes through the replay guard so xterm's auto
replies cannot leak to the shell as input.

The reused constant already excludes ?2004l, so the bracketed-paste protection
the live shell re-arms at its prompt is preserved.
2026-07-27 15:41:34 -07:00
nolainjin 17fc40eae6
fix(hooks): drain POSIX hook stdin without PATH (#10885)
Reviewed with an independent reproduction on macOS and Ubuntu 20.04. Fixed five test files that asserted the old literal strings, and switched the reader to `command -p cat` so it also survives hosts without /bin/cat.
2026-07-27 15:41:23 -07:00
Nemo cdd5ceb72b
fix(jira): render issue description/comment images with lightbox (#8938)
* fix(jira): render issue images and open them in a lightbox

Jira ADF media nodes were dropped when converting descriptions/comments
to Markdown, so screenshots never appeared in the Tasks drawer.

Download image attachments with authenticated Jira API access, embed them
as data URLs on issue/comment detail loads, and add a viewport-centered
lightbox. Closing with X/Esc only dismisses the preview, not the issue sheet.

* fix(jira): open comment images in the same lightbox as description

Jira issue comments still used compact markdown, so screenshots rendered
but could not expand. Use the document renderer for comment bodies, add a
regression test for the expand control, and sync MarkdownImageLightbox
locale keys.

* fix(jira): harden inline image handling

* fix(jira): harden inline image discovery, escaping, and downloads

Address PR review findings: correct media-attachment pairing, Server/DC
attachment lookup base path, markdown-safe external URLs, wider HTML
discovery with gated alt fallback, concurrent downloads outside the API
semaphore, and a main-process attachment data-URL cache with lower caps.

* fix(jira): Option A multi-same-name attachments and post-map media warns

Fix discovery so repeated alts (image.png) get distinct attachment ids,
flush resolution warns after ADF mapping using attachment-only stats,
clear attachment cache on clearToken with epoch-guarded singleflight,
and add Server comment path plus release-before-binary regression tests.

* fix(jira): simplify comment media request skip condition

Only needingCount determines whether to skip the attachment metadata
request — htmlIds alone cannot produce a download without needing
media. Add type annotation for mediaAttrs for clarity.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-07-27 15:34:20 -07:00
Neil 2bb3276a35
fix(cmd-j): focus the destination workspace's own terminal after a jump (#10695)
Co-authored-by: Orca <help@stably.ai>
2026-07-27 15:31:23 -07:00
OrcaWin 24706ccff0
fix(terminals): negotiate explicit close intent for paired runtimes (#10129) 2026-07-27 15:22:55 -07:00
Neil 0f91af821d
ci: parallelize PR checks and accelerate Vite builds (#10989)
* ci: parallelize and accelerate PR checks

* fix(ci): make accelerated checks runtime-safe

* fix(ci): address review findings

* fix(ci): retry transient Electron downloads

* test(ci): cover Electron download retry limits
2026-07-27 13:32:29 -07:00
SilvaViniF a1ad4714e9
fix(gitlab): render item descriptions and comments with document markdown variant (#9161)
Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 13:12:13 -07:00
OrcaWin cd05f2ff93
Implement robust orchestration primitives and connected-server workers (#9925) 2026-07-27 12:31:37 -07:00
Neil 8b154d686c
perf(runtime): remove timer clamps from cooperative yields (#10908)
* perf(runtime): remove timer clamps from cooperative yields

Renderer paste and input loops can schedule more than a thousand zero-delay timer yields for a maximum-size payload. Chromium clamps nested timers to 4ms, adding seconds of idle wall time.

Use MessageChannel tasks in renderer runtimes and setImmediate in Node while retaining a timer fallback for tests and unsupported environments.

* fix(runtime): preserve pacing and release yield callbacks

Adversarial review found that concurrent producers could retain resolved callbacks until global quiescence. Route renderer yields by token and delete each resolver before resuming its producer.

Keep timer pacing in terminal paste and accepted-write loops where SSH and local PTYs do not provide drain acknowledgement. Use the shared scheduler for the OpenCode scanner.
2026-07-27 12:21:14 -07:00
OrcaWin c140a51118
fix(worktrees): resolve a two-host project by the worktree's own host (#10634) (#10986)
* fix(worktrees): resolve a two-host project by the worktree's own host (#10634)

A project registered on both a local host and an SSH host permanently poisoned
every one of its workspaces with "Workspace identity is ambiguous across hosts.
Refresh projects and try again." Refresh could never help: nothing was stale,
both host setups were valid and intentional. The error survived restarts.

The ambiguity was manufactured. `resolveExactWorktreeRoute` starts from a
worktree that already carries exactly one `hostId`, then throws that away and
asks `resolveIndexedRepoOperationRoute` which host owns the *repo* — a question
with two right answers once a project spans hosts. Only the project spans hosts;
each worktree never does.

Route resolution now filters repo setups to the ones matching the worktree's own
host before looking for a transport, so a two-host project resolves as cleanly as
a one-host project. Genuine ambiguity still returns `ambiguous`.

Second half: the error escaped as an *uncaught renderer error* because passive
background paths — unread marking, activity bumps — called a helper that threw.
Those callers now degrade: `trySettingsForWorktreeOwner` returns null, the
passive update is skipped with a warning, and local state stays consistent.
Explicit user actions still surface the error.

* fix(worktrees): cover every passive path and warn once for ambiguous owners

Adversarial review found the routing fix sound but its coverage thin: only
markWorktreeUnread had an ambiguous-owner test, so restoring the throw in
clearWorktreeUnread or bumpWorktreeActivity would have reproduced the uncaught
renderer error with the suite still green. Both are now covered, verified by
mutation.

bumpWorktreeActivity also skipped silently where the other paths warned. It now
warns — but once per workspace, not per event: activity bumps fire on every PTY
event, so an unbounded warn would flood the console for exactly the users already
hitting this bug.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-27 12:19:18 -07:00
OrcaWin 560f853a40
fix(editor): save floating workspace markdown files (#10985) 2026-07-27 12:14:55 -07:00
Brennan Benson cf513adddc
feat(usage): price Claude 5 family and GPT-5.6 token usage (#10822)
* feat(usage): price Claude 5 family and GPT-5.6 token usage

Claude Opus 5, Sonnet 5, Fable 5 and Codex gpt-5.6 sol/terra/luna were
absent from the usage pricing tables, so their turns aggregated tokens
but reported no estimated cost.

Rates from Anthropic and OpenAI published pricing. Sonnet 5 gets no
long-context tier: Claude 4.6 and later bill the full 1M window flat.

Sonnet 5 uses the standard $3/$15 rate, not the $2/$10 introductory rate
that runs through 2026-08-31 — the table has no date dimension.

* fix(usage): price the bare gpt-5.6 alias and assert Opus 4.5 separately

OpenAI routes the bare `gpt-5.6` alias to Sol, but only the explicit
`-sol` / `-terra` / `-luna` IDs resolved, so alias-recorded sessions still
reported no cost. Match it exactly rather than by prefix so it cannot
swallow the tier IDs or a future cheaper variant.

Also split the Claude 5 shadowing guard into per-model breakdown
assertions and add the missing Opus 4.5 fixture the test name claimed.

* docs(usage): note Sonnet 5 uses standard, not introductory, rates
2026-07-27 12:06:23 -07:00
Jinwoo Hong 05603a2e78
fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459) (#10893)
* fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459)

Resource Manager decided a session was an "orphan" from the absence of a
renderer binding, then force-killed it with no prompt. Absence of a binding is
not evidence a session is idle — during restore the binding map is legitimately
empty, and deferred SSH sessions never appear in it at all. Live agent sessions
were destroyed this way, losing unrecoverable work.

Three gaps, one rule: only positive evidence authorizes destruction.

- `pty:listSessions` dropped `agentSessionOwners` at the IPC boundary, so the
  renderer could not see the one fact that proves work is running. It now
  reports `hasAgentOwner`, typed once in `shared/pty-listed-session.ts` so the
  main handler, both preload surfaces, and the renderer cannot drift.
- The binding index ignored `deferredSshSessionIdsByTabId` — sessions restore
  knows are live on an SSH host but has not reattached. No other binding source
  can see them.
- The bulk-kill handler filtered sessions separately from the button's count,
  so the set killed could differ from the set advertised. Both now call
  `selectUnboundDaemonSessions`.

The single-row kill path had the same defect: it skipped confirmation whenever
`bound` was false. `requiresKillConfirmation` now also holds for agent-owned
sessions, and snapshot-derived rows carry ownership across from the daemon list
rather than reporting `false`.

* fix(resource-manager): distinguish unprovable ownership from proven absence

Adversarial review of the previous commit found it committed the same class of
error it was fixing: it collapsed "no agent owns this" and "this provider cannot
tell me" into one boolean `false`, and both destructive paths read that as proof.

A daemon generation below the claim protocol, an older SSH relay, or the
in-process local fallback all list no owners for a session that may well have
one. `pty.ts` already encodes the rule at :613 — "only providers that serialize
claims may make listing absence authoritative" — and the new IPC row ignored it.
So after upgrading with a legacy daemon still holding a live agent terminal,
bulk cleanup would have destroyed it: exactly #8459, one layer down.

`hasAgentOwner: boolean` is now `agentOwnership: 'present' | 'absent' | 'unknown'`,
derived via `providesAgentSessionOwnerListings`. Only `absent` authorizes
destruction, so `unknown` protects and confirms.

Second defect, found independently by four review lenses: the deferred-SSH
bindings reached the bulk selector but not `mergeSnapshotAndSessions`, because
the merge call site re-listed the binding fields instead of reusing the object.
A deferred SSH session therefore rendered `bound: false`, and its single-row kill
skipped confirmation while bulk cleanup correctly spared it. The call site now
spreads `resourceSessionBindings`, and a parity test fails if any binding field
is re-listed inline — the drift itself is now impossible to reintroduce quietly.

The e2e ownership assertion was also weak: it checked only that a boolean
arrived. It now asserts the exact arm, and that the live local provider reports
`absent` rather than `unknown`, so a degenerate all-unknown implementation fails.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-27 11:52:55 -07:00
github-actions[bot] 94009f63ff Update README downloads badge 2026-07-27 13:20:45 +00:00
Neil 0e11ec38bb
fix(terminal): restore link hover after mouseleave (#10903)
* fix(terminal): restore link hover after mouseleave

* test(terminal): verify mouseleave listener cleanup

* test(terminal): assert link hover listener wiring
2026-07-27 03:29:35 -07:00
Neil fb26254a02
perf(usage): yield with setImmediate, not a clamped setTimeout(0) (#10892)
Co-authored-by: Orca <help@stably.ai>
2026-07-27 03:08:19 -07:00
Neil 165e4e0d1b
perf(agent-status): strip terminal control bytes by run, not per character (#10866)
* perf(agent-status): strip terminal control bytes by run, not per character

stripTerminalControl built its result with a per-character `+=`, allocating a
fresh string for every retained character. The Command Code status detector
calls it four times per PTY chunk — the scan text, the chunk-boundary variant,
and both previous-text lengths — so an agent pane paid that on every write.

Control bytes are sparse in real output, so copy the spans between them instead:
2.3x-2.6x from 5 KiB to 106 KiB chunks. Output is byte-identical, checked
exhaustively over every string up to length 4 across a 13-symbol control/unicode
alphabet plus 200k random strings (224,831 inputs, 0 mismatches).

* docs(agent-status): condense the run-copy rationale comments

Review feedback: both comments walked through the implementation. Keep one line
of non-obvious rationale each, per the repo's comment guidelines.

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

* test(agent-status): correct terminal strip benchmark

* test(agent-status): bound terminal strip benchmark

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 02:20:56 -07:00
github-actions[bot] e217ce60f2 release: v1.4.160-rc.0 2026-07-27 09:18:44 +00:00
Jinwoo Hong c53a12e11d
fix(relay): back off overloaded assignments (#10894)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-27 02:17:24 -07:00
Neil 3830851a83
fix(mobile): unblock iOS releases and prepare 0.0.36 (#10888)
* fix(mobile): block iOS uploads below the last shipped App Store version

The closed-train guard looked up each candidate version's own App Store
record, but a version only gets one once it is submitted for review.
0.0.34 reached TestFlight and was never submitted, so it had no record,
nothing looked closed, and the patch-bump walk stopped there — while
0.0.35 had already shipped. Apple rejected the upload after a 24-minute
build (90186 closed train, 90062 needs a higher CFBundleShortVersionString).

Fetch the highest closed version once and treat everything at or below it
as closed, comparing semver numerically so 0.0.10 outranks 0.0.9.

Also read appVersionState alongside appStoreState: the latter is
deprecated in App Store Connect API 3.3 and renames the shipped state to
READY_FOR_DISTRIBUTION, so reading only the old field would silently find
zero closed versions once Apple stops populating it.

* chore(mobile): prepare 0.0.36

app.json sat at 0.0.32 while 0.0.35 shipped on the App Store, because
release versions are resolved on the runner and never committed back.
Close the four-version drift so the checked-in version matches reality
and the iOS release no longer depends on the closed-train walk to find
an open version.

Bump Android versionCode 8 -> 9 in the same commit: the version is shared
between platforms, and shipping 0.0.36 with the code that already shipped
for 0.0.32 produces an APK that cannot install over the released build.
2026-07-27 01:53:11 -07:00
BingZ b96c2f0582
fix(remote): accelerate terminal recovery on resume/online (#8255)
* fix(remote): accelerate shared-control and pane recovery on resume/online

Narrow #8255 onto current main after #9774: fire pending shared-control
reconnect timers and pane recovery backoffs on system resume and browser
online, without replacing the per-pane recovery state machine or reconnect
banner UX.

* test(remote): cover online and occluded-resume recovery triggers

* fix(remote): centralize recovery acceleration

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-27 01:43:44 -07:00
Neil b59f893ee2
fix(tab-bar): keep tab menu items on one line and give every item an icon (#10882) 2026-07-27 01:14:57 -07:00
Neil 97e4776dfe
feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental) (#8549)
* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental)

Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed marketplace v0 with consent, provenance and
kill-list enforcement.

Theme, icon-theme and terminal-theme contributions are deferred to a
follow-up pass.

* fix(plugins): make unsupported marketplace listings unreachable by key

findPlugin() backs preview/install/previewInstalledUpdate via
requireListing(), so filtering only listPlugins() hid the catalog card
while leaving the dead install path reachable one click later.

* fix(plugins): fan Pi session-only status out to plugin subscribers

The providerSessionOnly early-return in applyNormalizedStatus emitted to
onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so
plugins subscribed to agent.status.changed silently missed every Pi
session_start event. Route both emit sites through one helper so a future
early return cannot drop the plugin tap again.

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

* plugins: drop dead code and hoist duplicated trust-boundary patterns

Cleanup pass over the P1 diff, no behavior change:

- Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus
  the now-vestigial `directories`/`signal` plumbing in `collectFiles`.
- Delete `resolveContainedPluginDirectory` (no callers).
- Delete `plugin-content-load-pool.ts`; it reimplemented the existing
  `mapWithConcurrency`, whose index arg also removes the pairing wrapper
  in `buildPluginList`.
- Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into
  the install-lockfile module; 11 sites hand-rolled these identically.
- Point the new reliability gate at the PR instead of gitignored docs
  paths, matching every other gate's link form.

* fix(plugins): retry plugin state renames on Windows AV/EPERM locks

Six plugin write paths (lockfile, provenance, current pointer, kill
list, marketplace cache, staged install dir) did a plain rename, so an
antivirus or indexer holding the target open surfaced as a failed
install. The repo already retries this hazard for issue #1507, but only
through a sync helper; these paths are all async.

Adds one bounded async retry + atomic write used by all six, and trims a
consent-provenance header that restated its own JSX.

* test(plugins): cover the Windows rename retry path

The retry loop shipped untested: both existing cases hit the non-retry path,
and the temp-cleanup test passed identically with the `finally` removed.
Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke.

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

* fix(plugins): pin bundled plugin resources to LF

Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived
as CRLF and verify-packaged-plugin-resources rejected it — the packaged build
could never pass on Windows. Reproduced locally: CRLF yields the exact CI
error, LF verifies clean. Files are already LF, so nothing renormalizes.

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

* test: guard the bundled-plugin LF pin against a CRLF checkout

The byte-hash mismatch only surfaced in Windows packaging CI. Assert the
.gitattributes pin and that a CRLF tree is rejected, so a regression fails
on any platform instead of waiting for a packaged Windows build.

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

* ci: trigger packaged-build check on bundled plugin resource changes

The launch tree is byte-hashed during packaging, but no trigger path covered
it — so the CRLF fix for that check would not have re-run the check. Add the
resources, verifier and .gitattributes paths that can break packaging.

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

* perf(plugins): rebuild the panel frame only when its baked theme values change

The revision keys the panel iframe, so every bump destroys the sandboxed
frame and its in-panel state. It counted root attribute mutations, but
--workspace-sidebar-live-width is written every rAF of a sidebar drag, so
dragging with a panel open blanked it ~60x/sec. Compare the two values the
shell actually bakes in instead.

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

* test: stop pinning a plugin name in the CRLF guard

The CRLF case rewrites every launch file, so the reported mismatch is
whichever plugin sorts first. P2 adds theme plugins that sort ahead of
orca-navigation-shortcuts, which broke the assertion there.

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

* style: drop stray blank lines left by the rebase resolutions

Both sides of the agent-hooks and orca-runtime conflicts contributed a
trailing blank, which oxfmt rejects. Whitespace only.

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

* test(plugins): stop the startup budget failing on machine load

P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite
parallelism, so the gate flaked. Widen it to catch an order-of-magnitude
regression instead; the no-worker/no-plugin-code assertions are the real
guarantee. Verified a 400ms regression still fails.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 01:14:33 -07:00
Neil 7dab1e86e2
perf(ssh): normalize watch event paths once per fs.changed batch (#10881)
Co-authored-by: Orca <help@stably.ai>
2026-07-27 01:02:48 -07:00
Neil 81eeb40ada
perf(cli): load only the handler group a command dispatches into (#10883)
Co-authored-by: Orca <help@stably.ai>
2026-07-27 01:01:25 -07:00
Neil 2b244fa0ea
fix(terminal): clear search highlights when the find bar closes (#10872)
Closing Ctrl+F left one match highlighted until the window was minimized
and restored.

xterm's DecorationService keys its SortedList on `decoration.marker.line`,
but `SortedList.delete()` only records an index and defers compaction,
while `Marker.dispose()` sets `line = -1` — mutating that same sort key.
After the first disposal the array is no longer sorted, so the binary
search inside `delete()` can miss a decoration that is present. It returns
false, `onDecorationRemoved` never fires, and the decoration stays live and
keeps painting. Repaints don't help; they faithfully re-paint a live
decoration, which is why only a window cycle appeared to fix it.

`clearDecorations()` disposes the active match before the match
highlights, which is exactly the order that trips this.

Patch `delete()` to retry once after compacting pending deletions, on the
miss path only, so the common bulk delete keeps its O(log n) search and
deferred batching. A 3000-trial randomized differential against upstream
semantics shows no behavior change for well-ordered lists.
2026-07-27 00:10:04 -07:00
gatsby74 a142c84ede
Fix diff notes overlapping following lines (#7803)
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-07-26 23:48:20 -07:00
Neil 5a6a9e0b28
fix(terminal): avoid flash while restoring parked terminals (#10871) 2026-07-26 23:46:39 -07:00
Neil a1a78da878
fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841) 2026-07-26 22:39:32 -07:00
Neil 58ef46d252
lint: guard the two perf bug shapes we fixed repeatedly (#10851) 2026-07-26 22:28:19 -07:00
Neil 8bee4bc62c
perf(source-control): share one path collator across the projection (#10850) 2026-07-26 22:26:49 -07:00
Jinjing 28b395ced2
fix(mobile): harden native chat send budgets, streams, and stop (#10814)
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-07-26 22:08:38 -07:00