* 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 in1e60ca87a4. 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 in1d2cd33c83. 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>
Orca
中文 · 日本語 · 한국어 · Español · Français · Português
The AI Orchestrator for 100x builders.
Run Codex, ClaudeCode, OpenCode or Pi side-by-side — each in its own worktree, tracked in one place.
Download Orca
Features
Also in the box:
- Quick open — Search across worktrees, files, agents, commands, and repo context without leaving your flow.
- Account switcher & usage tracking — See Claude and Codex usage and rate-limit resets, and hot-swap accounts without re-logging in.
- Rich repo previews — Preview Markdown, images, PDFs, and repo docs in the workspace.
- Computer Use — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
- Notifications and unread state — Know when an agent finishes or needs attention, then mark threads unread to come back later.
- And many, many more — we ship daily, so this list is perpetually behind. The changelog is the real feature list.
Supported Agents
Works with any CLI agent — if it runs in a terminal, it runs in Orca.
Claude Code
Codex
Grok
Cursor
GitHub Copilot
OpenCode
MiMo Code
Amp
OpenClaude
Antigravity
Pi
oh-my-pi
Hermes Agent
Devin
Goose
Auggie
Autohand Code
Charm
Cline
Codebuff
Command Code
Continue
Droid
Kilocode
Kimi
Kiro
Mistral Vibe
Qwen Code
Rovo Dev
+ any CLI agent
Install
Desktop — macOS, Windows, Linux
- Download from onOrca.dev
- Or grab a build directly: macOS Apple Silicon · macOS Intel · Windows (.exe) · Linux AppImage · All builds
- Running
orca serveon a headless Linux server? See the headless Linux server guide.
Or via a package manager:
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
Mobile Companion — iOS, Android
Pair with your desktop app to monitor and steer your agents from your phone.
- iOS: Download on the App Store or join TestFlight
- Android: Download APK 0.0.32
Community & Support
-
Discord: Join the community on Discord.
-
Twitter / X: Follow @orca_build for updates and announcements.
-
WeChat: All other groups are full, now we're on group 5.
-
Feedback & Ideas: We ship fast. Missing something? Request a new feature.
-
Privacy: See the privacy & telemetry docs for what anonymous usage data Orca collects and how to opt out.
-
Show Support: Star this repo to follow along with our daily ships.
Developing
Want to contribute or run locally? See our CONTRIBUTING.md guide.
Signed Builds
Windows code signing sponored/provided by SignPath.io, certificate by SignPath Foundation.
License
Orca is free and open source under the MIT License.










