Skip to content

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

Merged
nwparker merged 5 commits into
mainfrom
nwparker/bugbash-9497-shortcut-order
Jul 26, 2026
Merged

nwparker merged 5 commits into
mainfrom
nwparker/bugbash-9497-shortcut-order

Conversation

@nwparker

@nwparker nwparker commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #9497.

What was wrong

Cmd/Ctrl+1–9 jumps to the Nth workspace card. The handler asks getVisibleWorktreeIds() for the order.

While the sidebar is open, WorktreeList publishes the exact list of IDs it rendered, so the numbering matches the cards. While the sidebar is closed, WorktreeList is unmounted and the function fell back to a flat, membership-ordered list — a filtered-and-sorted set of workspace IDs that never went through the sidebar's row pipeline.

That flat list ignores everything the pipeline does to arrange cards:

  • main-worktree hoisting — each repo's main workspace is anchored to the top of its group
  • grouping by repo/project/status, and project group ordering
  • pinning — pinned workspaces are lifted into their own section (policy-dependent)
  • collapsed groups — members render no card at all, so they own no number
  • host sections — with a host filter on, workspaces are re-bucketed per host
  • folder workspaces — these only ever enter via folder-workspace rows, so the flat list omitted them entirely

So closing the sidebar silently renumbered the workspaces. The reporter's "Cmd+1 selects the second workspace" is exactly the main-worktree hoisting case: the flat list kept membership order, while the sidebar had hoisted main to position 1.

ELI5

The sidebar is a list of doors, numbered top to bottom. When the sidebar was open, door #1 was the one your eyes landed on first. When you closed the sidebar, the app stopped looking at the doors and instead read an unsorted inventory sheet — same doors, different order. Pressing "1" opened whatever door happened to be first on the sheet, which usually wasn't the door you'd counted as first.

Now, with the sidebar closed, the app arranges the doors the same way the sidebar would have before counting — so "1" always means the same door.

The fix

New module rendered-sidebar-worktree-order.ts replays the sidebar's own pipeline against a store snapshot:

buildRows(...) → addHostSectionRows(...) → getRenderedWorktreesInSidebarOrder(...)

This reuses the ordering logic rather than re-deriving it, so the two paths can't drift apart later. Its arguments mirror WorktreeList's real call sites one-for-one.

Two deliberate simplifications, both proven not to affect order:

  • The four placeholder/imported/inbox/pending inputs are passed empty — they only ever emit their own row types, never item or folder-workspace rows (the only two the order reads).
  • hostLabelById is undefined — it feeds display-only host context labels, never row order.

addHostSectionRows is skipped when there's no host filter, where it is a pass-through — so a keystroke doesn't build the whole host registry for nothing.

Second, separate bug fixed

The published-order cache was guarded by if (_cachedVisibleIds.length > 0). An empty array is ambiguous: it means both "unmounted" and "mounted, but every card is collapsed or filtered away". In the second case the guard fell through to the flat list, so a mounted sidebar showing zero cards could still jump to a workspace with no visible card. The published order is now string[] | null, where null means unmounted and [] is a real, authoritative empty order.

Fix proof

Cmd+1 order: sidebar open vs closed, before and after

Both the "before" and "after" closed-sidebar orders in this image are produced by running the real code paths against a seeded store — they are not hand-authored mockups. Source: evidence/9497-shortcut-order.

9 new tests drive the real getVisibleWorktreeIds() entry point against a seeded store — not the internal helper — so they cover the exact path the Cmd+N handler takes.

Mutation-tested (the tests fail when the fix is removed, so they aren't tautological):

Mutation Result
Restore the old flat fallback 9 of 9 fail. Headline case returns ['wt-feature','wt-main'] where the sidebar renders ['wt-main','wt-feature'] — the reported symptom, reproduced exactly.
Restore the length > 0 guard Exactly 1 fails — the explicitly-empty-order test, isolating the second bug.
Both restored (i.e. main) Baseline green.

Coverage: main-worktree hoisting, collapsed groups, folder workspaces, explicit host filtering, both pinned-display policies, smart + comparator sort modes, and published-vs-recomputed precedence.

Proof of no regressions

  • 1674/1674 tests pass across src/renderer/src/components/sidebar/ + useIpcEvents.test.ts (184 files)
  • tsc -p config/tsconfig.tc.web.json --noEmit → exit 0
  • oxlint src/renderer/src/components/sidebar/ → exit 0
  • useIpcEvents.test.ts 81/81 still green — its 15 vi.doMock('@/components/sidebar/visible-worktrees', …) sites still intercept, because the public entry points deliberately stayed in that module (the new module is a leaf it calls, not a replacement)
  • No max-lines disable added; visible-worktrees.ts stays within its 300-line budget

Performance

getVisibleWorktreeIds() now runs the row pipeline. Its only production caller is the onJumpToWorktreeIndex IPC handler — once per keystroke, not in any render path or high-frequency event. Measured on this machine:

Workspaces Cost per call
50 0.12 ms
500 0.78 ms

Scaling is linear (10× the workspaces → 10.3× the time), so there's no hidden quadratic. Sub-millisecond at 500 workspaces is far inside a 16 ms frame.

Trade-offs

  • The closed-sidebar path does more work than before (a full row-pipeline pass instead of a flat map). Quantified above: ~0.1–0.8 ms, once per keypress. Correctness is worth it, and the alternative — caching the last rendered order — cannot number a workspace created while the sidebar was closed, since a retained cache can only ever remove IDs it already had. There is a test for exactly that case.
  • Ordering rules now have one owner. If someone adds a new row type that should be numbered, they must add it to getRenderedWorktreesInSidebarOrder — but that was already true for the sidebar itself, so this removes a divergence rather than adding an obligation.
  • No user-visible regression. The numbering only changes where it was previously wrong (sidebar closed); the open-sidebar path is untouched and still uses the published order verbatim.

Cross-platform

Ordering logic only — no platform branches added or touched. The Cmd-vs-Ctrl decision happens upstream in shared/window-shortcut-policy.ts, which is already platform-aware and unmodified. Nothing here is macOS-specific despite the issue's os:macos label; Linux/Windows users were hitting the same wrong ordering via Ctrl+N.

Relationship to #9548

@OnlyYu1996 opened #9548 for this issue first, and their diagnosis of the open-vs-closed split was correct — credit to them for finding it. That PR prunes the retained cache on unmount. I went a different way because pruning can only ever remove stale IDs: a workspace created while the sidebar is closed still gets no number, and the ordering rules stay duplicated in two places. This PR derives the order from the sidebar's own pipeline instead, which covers both. Happy to close either one in favor of the other.

Review loop

Adversarially reviewed by two independent models (GPT-5.6-Sol and Fable). 4 rounds to clean.

Round Focus Verdict
1 Broad correctness + tests Not clean. Produced a stronger test file driving the real getVisibleWorktreeIds() entry point (adopted after I verified it), but it also asserted a fabricated host-section order — corrected against host-section-rows.ts:265-270, which orders by the host registry, not by the filter argument.
2 Argument fidelity + sentinel lifecycle Inconclusive (reviewers timed out). I did this pass myself and found the one real fidelity risk: effectiveCollapsedGroups vs state.collapsedGroups. Resolved — the divergence needs an open agent-send popover, which needs a mounted sidebar, which publishes its order and never reaches this path. Pinned by a test.
3 Row-order fidelity; sentinel staleness CLEAN ×2. No argument mismatch affecting order/membership. StrictMode setup→cleanup→setup ends correct; useReusedArrayIdentity only reuses identity when every element is strictly equal, so a content change always re-publishes.
4 Dedupe occurrence; folder-workspace id namespace; false test assertions CLEAN. getRenderedWorktreesInSidebarOrder already emits one row per workspace at its preferred position, so the trailing new Set has no duplicate to mis-choose; folder-workspace keys resolve end-to-end via getKnownWorktreeById.

Two review comments were acted on rather than argued: the module JSDoc was trimmed from 16 lines to 7 per AGENTS.md's concise-comment rule, and a needsHostSections note that misstated the cost was corrected.

An independent reviewer also statically checked the headline test against the pre-fix commit c06bf64b48 and reproduced the failure — pre-fix ordering yields ['wt-feature','wt-main'] where the test requires ['wt-main','wt-feature'].

@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5600c13b-3c11-4912-abc4-51d9c0183efc

📥 Commits

Reviewing files that changed from the base of the PR and between b3964df94e2681365e38dddc33575ac936046a06 and 6b2d2e7.

📒 Files selected for processing (5)
  • src/renderer/src/components/sidebar/WorktreeList.tsx
  • src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts
  • src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts
  • src/renderer/src/components/sidebar/visible-worktrees.ts
  • src/renderer/src/components/sidebar/worktree-list-host-filtering.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts
  • src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts
  • src/renderer/src/components/sidebar/worktree-list-host-filtering.ts
  • src/renderer/src/components/sidebar/WorktreeList.tsx

📝 Walkthrough

Walkthrough

The sidebar now centralizes execution-host visibility filtering for project groups and folder workspaces. Closed-sidebar Cmd+1–9 ordering is computed through the rendered sidebar row pipeline, including host sections, pinned workspaces, collapsed groups, folder workspaces, and sorting behavior. Published worktree IDs are nullable so unmounted sidebars trigger recomputation, while explicitly published empty orders remain authoritative. New tests cover these ordering and host-filtering cases.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the bug, fix, tests, and review, but it does not follow the repository's required template sections. Add the required sections: Summary, Screenshots, Testing checklist, AI Review Report, Security Audit, and Notes.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: closed-sidebar Cmd/Ctrl+1-9 now matches rendered card order.
Linked Issues check ✅ Passed The changes address #9497 by preserving the same visible workspace order whether the sidebar is open or closed.
Out of Scope Changes check ✅ Passed The added helpers, helper tests, and visibility-cache changes all support the closed-sidebar ordering fix and are in scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/renderer/src/components/sidebar/worktree-list-host-filtering.ts (1)

2-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Migrate the remaining inline duplicate in computeVisibleWorktreeIds to this helper.

visible-worktrees.ts's computeVisibleWorktreeIds still inlines the identical visibleWorkspaceHostIds ?? (workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [workspaceHostScope]) → new Set(...) derivation instead of calling getVisibleSidebarHostIdSet. This is exactly the class of drift this PR's own rationale warns about ("the sidebar render path and the Cmd+1–9 order must apply the same host filtering, or the numbering drifts"). Since computeVisibleWorktreeIds also feeds the closed-sidebar recompute path, a future change to this helper's semantics could silently diverge from that inline copy.

♻️ Suggested consolidation in visible-worktrees.ts
// computeVisibleWorktreeIds, inside visible-worktrees.ts
const visibleHostIdSet = getVisibleSidebarHostIdSet(
  opts.visibleWorkspaceHostIds,
  opts.workspaceHostScope
)
if (visibleHostIdSet) {
  all = all.filter((w) => {
    const repo = opts.repoMap.get(w.repoId)
    if (!repo) return false
    const hostId = getWorktreeExecutionHostId(w, repo, opts.defaultHostId)
    return visibleHostIdSet.has(hostId)
  })
}

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4266fd6a-139d-4077-8810-f00d0fbbebee

📥 Commits

Reviewing files that changed from the base of the PR and between c67aadb and d1bf65c.

📒 Files selected for processing (5)
  • src/renderer/src/components/sidebar/WorktreeList.tsx
  • src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts
  • src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts
  • src/renderer/src/components/sidebar/visible-worktrees.ts
  • src/renderer/src/components/sidebar/worktree-list-host-filtering.ts

@nwparker
nwparker force-pushed the nwparker/bugbash-9497-shortcut-order branch from d1bf65c to b3964df Compare July 26, 2026 07:50
@nwparker

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (07671d4a06) — no conflicts.

Two sidebar commits landed since this branch was cut (4ff2f51782 shared agent-orchestration index, 28dfc13654 CLI-created workspace filtering), so I re-validated against them rather than merging stale:

  • 1689/1689 tests pass across src/renderer/src/components/sidebar/ + useIpcEvents.test.ts (185 files — up from 1674/184, the new tests are green too)
  • tsc -p config/tsconfig.tc.web.json --noEmit → exit 0
  • oxlint src/renderer/src/components/sidebar/ → exit 0
  • The 9 new tests still pass and the mutation proof still holds.

@nwparker

Copy link
Copy Markdown
Contributor Author

CI note: verify red on the first post-rebase run was a Docker Hub outage, not this change

Run 30193514168 failed in Verify Git binary compatibility matrix:

Error: Command failed: docker run --rm --network=none ... alpine/git:v2.49.1 -c safe.directory=/repo --version
Unable to find image 'alpine/git:v2.49.1' locally
docker: Error response from daemon: Head "https://registry-1.docker.io/v2/alpine/git/manifests/v2.49.1":
  received unexpected HTTP status: 502 Bad Gateway

The suite never executed — 4 tests | 4 skipped, failing at image pull. The other two legs of the same matrix passed on this exact commit: source-built Git 2.25.5 → 4/4, alpine/git:edge-2.38.1 → 4/4.

This PR touches only src/renderer/src/components/sidebar/*; it has no Git-binary surface. Re-running the failed job.

@nwparker

Copy link
Copy Markdown
Contributor Author

Follow-up: the second verify failure is a pre-existing break on main, also not this PR

The re-run got past the Docker outage and surfaced a different, real failure:

FAIL  src/main/persistence.test.ts > Store > derives fresh default profiles without branch
AssertionError: expected [ 'status', 'unread', 'issue', …(7) ] to deeply equal [ 'status', 'unread', 'issue', …(6) ]
    "automation",
+   "cli",
    "comment",

I reproduced it on pristine origin/main (54e22402bc) with none of this PR's code present — 1 failed | 416 passed. Cause: #10712 added 'cli' to DEFAULT_WORKTREE_CARD_PROPERTIES but left this one assertion listing the pre-'cli' array, so verify has been red on every commit since 28dfc13654.

This PR touches only src/renderer/src/components/sidebar/*; it is byte-identical to main in both src/main/persistence.test.ts and src/shared/worktree-card-properties.ts (git diff between the branches is empty for both).

Fix sent as #10722. Once that lands I'll rebase this PR onto it so verify here can go green on its own merits — I'd rather not merge onto a red main.

nwparker added 5 commits July 26, 2026 01:40
…e sidebar is closed

The shortcut numbering read a flat, membership-ordered list whenever
WorktreeList was unmounted, so grouping, pinning, main-worktree hoisting
and collapsed groups were all ignored and Cmd+N jumped to the wrong
workspace (#9497).

Reuse the sidebar's own row pipeline (buildRows -> addHostSectionRows ->
getRenderedWorktreesInSidebarOrder) to derive the order from a store
snapshot, instead of re-deriving the ordering rules a second time.

Also fixes a mounted-sidebar case: the old `length > 0` cache check
treated a legitimately empty rendered order (everything collapsed or
filtered) as "not yet rendered" and fell through to the flat list. The
published order is now `string[] | null`, where null means unmounted.
…point

Exercise getVisibleWorktreeIds() against a seeded store instead of calling
the internal order helper directly, so the tests cover the path the Cmd+N
handler actually takes.

Adds coverage for folder workspaces (which the old flat fallback dropped
entirely), explicit host filtering, both pinned-display policies, and the
smart/comparator sort modes.

Mutation-checked: restoring the flat fallback fails all 8; restoring the
`length > 0` published-order guard fails the empty-order test alone.
WorktreeList expands the send target's group via effectiveCollapsedGroups,
while the closed-sidebar path reads state.collapsedGroups. That divergence
is only sound because the override requires a mounted list, which publishes
its order and short-circuits the recompute entirely.

Pin both halves so a future change that consults the override off-screen,
or that stops honoring the published order, fails here.
Trim the module JSDoc and the collapse-override test note to the
non-obvious "why" only, per AGENTS.md.
On <=1 host the skipped call no-ops after the registry is built, so the
cost is the wasted registry build, not a skipped shortcut.
@nwparker
nwparker force-pushed the nwparker/bugbash-9497-shortcut-order branch from b3964df to 6b2d2e7 Compare July 26, 2026 08:44
@nwparker

Copy link
Copy Markdown
Contributor Author

Rebased onto the main repair — verify should now go green on its own merits

main is fixed (#10722, merged as 4326707285), so this PR is rebased onto it. New tip: 6b2d2e7503.

  • Rebase was clean; all 5 commits replayed. git diff b3964df94e HEAD -- src/renderer/src/components/sidebar/ is empty — the reviewed code is byte-identical, only the base moved.
  • Re-validated on the new base: 1689/1689 tests across 185 files (two consecutive runs), tsc exit 0, oxlint exit 0.

One transparency note: on the first post-rebase run, WorktreeCard.lineage.test.tsx and WorktreeCard.pinned-repo-icon.test.tsx hit 15000 ms timeouts. Both pass 5/5 standalone in 4.5 s and both passed on two subsequent full runs of the identical command — parallel-load flakiness in files this PR does not touch. I checked rather than assumed, because that same command had passed on pristine main.

@nwparker
nwparker merged commit 03b65d0 into main Jul 26, 2026
1 check passed
@nwparker
nwparker deleted the nwparker/bugbash-9497-shortcut-order branch July 26, 2026 08:51
OrcaWin added a commit that referenced this pull request Jul 27, 2026
…10697)

* fix(browser): retry a transient Windows lock on the Chromium cookie snapshot copy

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(github): harden SSH alias resolution

* Update README downloads badge

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

* fix(mobile): recover unreliable relay connections

* test(mobile): use valid raster preview fixtures

---------

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

* release: v1.4.157-rc.0

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

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

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

* fix(history): quarantine unreadable recovery files

* fix(history): preserve mixed recovery generations

* fix(history): reanchor reconciled live sessions

* fix(history): serialize final checkpoint queue

* fix(history): drain sleep shutdowns before disconnect

* fix(history): restore legacy wide sessions

* fix(history): preserve malformed mixed logs

* fix(history): preserve malformed log tails

* refactor tests to reduce file size

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

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

* test(history-recovery): consolidate checkpoint assertions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(gitlab): integrate sheet controls into header

---------

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

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

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

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

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

Closes #9974

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

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

* test(editor): verify word wrap shortcut routing

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* fix(speech): refresh SenseVoice catalog metadata

---------

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

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

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

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

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

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

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

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

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

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

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

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

* fix(speech): refresh SenseVoice catalog metadata

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* perf(terminal): bound retained WebGL contexts

* fix(terminal): harden retained WebGL lifecycle

* fix(terminal): preserve healthy WebGL on wake

* fix(terminal): preserve reveal recovery ordering

---------

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

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

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

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

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

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

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

* fix(cli): bound orchestration ask timeouts

* fix(cli): harden remote timeout boundaries

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(gpu-fallback): ask before restarting

---------

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

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

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

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

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

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

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

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

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

Implements STA-2376.

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

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

* fix(daemon): prevent false lifecycle telemetry

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(daemon): recover writes after endpoint respawn

* fix(terminal): remount panes after daemon death

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #10517

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

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

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

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

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

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

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

* release: v1.4.159-rc.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* perf(git): read both diff blobs concurrently

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix diff notes overlapping following lines (#7803)

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

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

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

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

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

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

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

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

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

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

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

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

Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed m…
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…tablyai#10697)

* fix(browser): retry a transient Windows lock on the Chromium cookie snapshot copy

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(github): harden SSH alias resolution

* Update README downloads badge

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

* fix(mobile): recover unreliable relay connections

* test(mobile): use valid raster preview fixtures

---------

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

* release: v1.4.157-rc.0

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

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

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

* fix(history): quarantine unreadable recovery files

* fix(history): preserve mixed recovery generations

* fix(history): reanchor reconciled live sessions

* fix(history): serialize final checkpoint queue

* fix(history): drain sleep shutdowns before disconnect

* fix(history): restore legacy wide sessions

* fix(history): preserve malformed mixed logs

* fix(history): preserve malformed log tails

* refactor tests to reduce file size

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

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

* test(history-recovery): consolidate checkpoint assertions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(gitlab): integrate sheet controls into header

---------

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

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

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

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

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

Closes #9974

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

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

* test(editor): verify word wrap shortcut routing

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* fix(speech): refresh SenseVoice catalog metadata

---------

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

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

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

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

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

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

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

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

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

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

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

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

* fix(speech): refresh SenseVoice catalog metadata

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* perf(terminal): bound retained WebGL contexts

* fix(terminal): harden retained WebGL lifecycle

* fix(terminal): preserve healthy WebGL on wake

* fix(terminal): preserve reveal recovery ordering

---------

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

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

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

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

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

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

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

* fix(cli): bound orchestration ask timeouts

* fix(cli): harden remote timeout boundaries

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(gpu-fallback): ask before restarting

---------

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

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

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

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

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

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

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

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

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

Implements STA-2376.

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

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

* fix(daemon): prevent false lifecycle telemetry

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(daemon): recover writes after endpoint respawn

* fix(terminal): remount panes after daemon death

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #10517

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

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

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

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

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

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

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

* release: v1.4.159-rc.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* perf(git): read both diff blobs concurrently

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix diff notes overlapping following lines (#7803)

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

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

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

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

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

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

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

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

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

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

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

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

Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed m…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Cmd+number selects a different workspace when the sidebar is closed

1 participant